Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/quiet-rails-spans.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"posthog-ruby": minor
"posthog-rails": patch
---

Add an optional context span processor for application OpenTelemetry spans. Rails request context adds session and person IDs to the active recording request span and preserves explicit span attributes.
18 changes: 12 additions & 6 deletions lib/posthog/internal/context.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require 'posthog/internal/span_context'

module PostHog
module Internal
# Internal request/fiber-local context applied to capture calls.
Expand All @@ -12,10 +14,12 @@ module Internal
# server-side SDK globally stateful per user.
class Context
STORAGE_KEY = :posthog_context
UNBOUND_OWNER = Object.new.freeze

attr_reader :distinct_id, :session_id, :properties
attr_reader :distinct_id, :session_id, :properties, :owner

def initialize(distinct_id: nil, session_id: nil, properties: {})
def initialize(distinct_id: nil, session_id: nil, properties: {}, owner: UNBOUND_OWNER)
@owner = owner
@distinct_id = distinct_id
@session_id = session_id
@properties = properties ? properties.dup : {}
Expand All @@ -38,17 +42,18 @@ def self.current=(context)
end
end

def self.with_context(data = nil, fresh: false, **kwargs)
def self.with_context(data = nil, fresh: false, owner: UNBOUND_OWNER, **kwargs)
previous_context = current
raise ArgumentError, 'with_context requires a block' unless block_given?

self.current = resolve(merge_data_and_kwargs(data, kwargs), previous_context, fresh: fresh)
self.current = resolve(merge_data_and_kwargs(data, kwargs), previous_context, fresh: fresh, owner: owner)
SpanContext.apply_current
yield
ensure
self.current = previous_context
end

def self.resolve(data, parent, fresh: false)
def self.resolve(data, parent, fresh: false, owner: UNBOUND_OWNER)
data = normalize_data(data)

parent_properties = fresh || parent.nil? ? {} : parent.properties
Expand All @@ -61,7 +66,8 @@ def self.resolve(data, parent, fresh: false)
new(
distinct_id: data[:distinct_id] || (fresh || parent.nil? ? nil : parent.distinct_id),
session_id: data[:session_id] || (fresh || parent.nil? ? nil : parent.session_id),
properties: properties
properties: properties,
owner: owner.equal?(UNBOUND_OWNER) && parent ? parent.owner : owner
)
end
private_class_method :resolve
Expand Down
34 changes: 34 additions & 0 deletions lib/posthog/internal/span_context.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true

module PostHog
module Internal
class SpanContext
def self.apply(span)
context = Context.current
return unless context && span&.recording? && span.respond_to?(:attributes)

return unless context.owner.equal?(Context::UNBOUND_OWNER) || context.owner&.enabled?

attributes = span.attributes || {}
{
'posthogDistinctId' => context.distinct_id,
'sessionId' => context.session_id
}.each do |key, value|
next if value.nil? || value == '' || attributes.key?(key)

span.set_attribute(key, value.is_a?(String) ? value.dup.freeze : value)
end
rescue StandardError
nil
end

def self.apply_current
return unless defined?(::OpenTelemetry::Trace)

apply(::OpenTelemetry::Trace.current_span)
rescue StandardError
nil
end
end
end
end
21 changes: 21 additions & 0 deletions lib/posthog/otel/context_span_processor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

require 'posthog/internal/context'

module PostHog
class ContextSpanProcessor
def on_start(span, _parent_context)
Internal::SpanContext.apply(span)
end

def on_finish(_span); end

def force_flush(**_options)
0
end

def shutdown(**_options)
0
end
end
end
10 changes: 10 additions & 0 deletions posthog-rails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ SDK usage examples and code snippets live in the official documentation so they
- [Ruby on Rails framework docs](https://posthog.com/docs/libraries/ruby-on-rails)
- [Ruby library docs](https://posthog.com/docs/libraries/ruby)

## OpenTelemetry span context (optional)

Request context adds missing `sessionId` and `posthogDistinctId` attributes to an active recording OpenTelemetry SDK span. Register the optional `PostHog::ContextSpanProcessor` on the application's existing provider for child spans. OpenTelemetry SDK 1.0.0 or later is supported and remains optional.

The request keeps the Rails facade client that owned it at entry. A missing or disabled owner adds no PostHog span attributes. A valid-key test-mode client remains enabled.

Existing canonical attributes take precedence. Missing IDs stay absent. Spans keep their captured IDs after the request ends. Separate threads, fibers, and jobs need explicit context transfer; concurrent Rails fibers require fiber-isolated execution state.

See the [Rails trace context guide](https://posthog.com/docs/libraries/ruby-on-rails#trace-context) for setup, middleware order, and trust limits. The bridge does not replace the application's provider, exporter, or sampler. Optional Rails log forwarding uses the same request context.

## PostHog Logs (optional)

`posthog-rails` can forward `Rails.logger` output to [PostHog Logs](https://posthog.com/docs/logs)
Expand Down
2 changes: 1 addition & 1 deletion posthog-rails/lib/posthog/rails/request_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def initialize(app)
def call(env)
request = build_request(env)

Internal::Context.with_context(context_data(request), fresh: true) do
Internal::Context.with_context(context_data(request), fresh: true, owner: PostHog.client) do
@app.call(env)
end
end
Expand Down
5 changes: 5 additions & 0 deletions public_api_snapshot.txt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ instance_method PostHog::Client#reload_feature_flags()
instance_method PostHog::Client#shutdown(timeout: ...)
class_method PostHog::Client.logger()
class_method PostHog::Client.reset_instance_tracking!()
class PostHog::ContextSpanProcessor
instance_method PostHog::ContextSpanProcessor#force_flush(**_options)
instance_method PostHog::ContextSpanProcessor#on_finish(_span)
instance_method PostHog::ContextSpanProcessor#on_start(span, _parent_context)
instance_method PostHog::ContextSpanProcessor#shutdown(**_options)
module PostHog::Defaults
module PostHog::Defaults::BackoffPolicy
constant PostHog::Defaults::BackoffPolicy::MAX_TIMEOUT_MS: Integer
Expand Down
1 change: 1 addition & 0 deletions scripts/public_api_snapshot.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def load_core_sdk
require 'posthog'
# Opt-in integration shipped in the core gem; loaded here so its public surface is tracked too.
require 'posthog/mcp'
require 'posthog/otel/context_span_processor'
end

def captured_core_api
Expand Down
3 changes: 2 additions & 1 deletion spec/posthog/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2185,10 +2185,11 @@ def release
let(:trace_id) { '0123456789abcdef0123456789abcdef' }
let(:span_id) { '0123456789abcdef' }
let(:span_context) { double('span context', valid?: true, hex_trace_id: trace_id, hex_span_id: span_id) }
let(:span) { double('span', context: span_context, recording?: false) }

before do
stub_const('OpenTelemetry::Trace', trace_api)
allow(trace_api).to receive(:current_span).and_return(double('span', context: span_context))
allow(trace_api).to receive(:current_span).and_return(span)
end

[
Expand Down
33 changes: 33 additions & 0 deletions spec/posthog/context_span_processor_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# frozen_string_literal: true

require 'spec_helper'
require 'open3'
require 'posthog/otel/context_span_processor'

RSpec.describe PostHog::ContextSpanProcessor do
it 'loads and runs without loading optional OpenTelemetry gems' do
script = <<~RUBY
require 'posthog'
require 'posthog/otel/context_span_processor'
context = PostHog.const_get(:Internal).const_get(:Context)
context.with_context(distinct_id: 'local-person', session_id: 'local-session') do
raise 'context missing' unless context.current.distinct_id == 'local-person'
end
raise 'context leaked' unless context.current.nil?
raise 'OpenTelemetry loaded' if defined?(OpenTelemetry)
processor = PostHog::ContextSpanProcessor.new
raise 'flush failed' unless processor.force_flush(timeout: 1) == 0
raise 'shutdown failed' unless processor.shutdown(timeout: 1) == 0
RUBY
output, status = Open3.capture2e(RbConfig.ruby, '-Ilib', '-e', script)

expect(status.success?).to be(true), output
end

it 'does not let a failed context lookup break application tracing' do
context = PostHog.const_get(:Internal).const_get(:Context)
allow(context).to receive(:current).and_raise('context unavailable')

expect { described_class.new.on_start(nil, nil) }.not_to raise_error
end
end
Loading
Loading