Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/silent-games-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-ruby": patch
---

Allow framework job scopes to preserve unrelated caller spans while retaining event and child-span context.
5 changes: 5 additions & 0 deletions .changeset/tired-trees-dress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-rails": minor
---

Carry session and person IDs from requests into Active Job execution when automatic job instrumentation is enabled.
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ gem 'concurrent-ruby', require: 'concurrent'
gem 'irb'

group :development, :test do
gem 'activejob', '~> 7.1'
gem 'activesupport', '~> 7.1'
gem 'commander', '~> 5.0'
gem 'mcp', '>= 1.4'
Expand Down
8 changes: 8 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ GEM
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.2.3.1)
activesupport (= 7.2.3.1)
globalid (>= 0.3.6)
activesupport (7.2.3.1)
base64
benchmark (>= 0.3)
Expand Down Expand Up @@ -60,6 +63,8 @@ GEM
erb (4.0.4.1)
cgi (>= 0.3.3)
erubi (1.13.1)
globalid (1.4.0)
activesupport (>= 6.1)
haml (7.2.0)
temple (>= 0.8.2)
thor
Expand Down Expand Up @@ -236,6 +241,7 @@ PLATFORMS
x86_64-linux-musl

DEPENDENCIES
activejob (~> 7.1)
activesupport (~> 7.1)
commander (~> 5.0)
concurrent-ruby
Expand All @@ -255,6 +261,7 @@ DEPENDENCIES
CHECKSUMS
actionpack (7.2.3.1) sha256=b66afe7f937273270cb63f03bde7af7ba850017867766e8848d06d3e12e1e4ca
actionview (7.2.3.1) sha256=de19b86843391762ac24a6287c30fbba11cd475fa4d4b664924d5fb7a2f1ff7c
activejob (7.2.3.1) sha256=0bc4227ce371b82da119cd27ed91e0deb9b744bbfa266b86e4bd8d1e2a8f6ed8
activesupport (7.2.3.1) sha256=11ebed516a43a0bb47346227a35ebae4d9427465a7c9eb197a03d5c8d283cb34
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
Expand All @@ -274,6 +281,7 @@ CHECKSUMS
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
erb (4.0.4.1) sha256=47e2a70922ba4cec8c23c2304ad7e581665c37a297cb9a22a860162c7727ee24
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427
haml (7.2.0) sha256=87fd2b71f7feab1724337b090a7d767f5ab2d42f08c974f3ead673f18cfcd55a
hana (1.3.7) sha256=5425db42d651fea08859811c29d20446f16af196308162894db208cac5ce9b0d
hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
Expand Down
1 change: 1 addition & 0 deletions gemfiles/otel.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
eval_gemfile '../Gemfile'

gem 'opentelemetry-exporter-otlp-logs'
gem 'opentelemetry-instrumentation-active_job'
gem 'opentelemetry-logs-sdk', '>= 0.6.0'
gem 'opentelemetry-sdk'
26 changes: 24 additions & 2 deletions gemfiles/otel.gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ GEM
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.2.3.1)
activesupport (= 7.2.3.1)
globalid (>= 0.3.6)
activesupport (7.2.3.1)
base64
benchmark (>= 0.3)
Expand Down Expand Up @@ -60,6 +63,8 @@ GEM
erb (4.0.4.1)
cgi (>= 0.3.3)
erubi (1.13.1)
globalid (1.4.0)
activesupport (>= 6.1)
google-protobuf (4.36.2)
bigdecimal
rake (~> 13.3)
Expand Down Expand Up @@ -145,12 +150,22 @@ GEM
opentelemetry-logs-sdk (~> 0.1)
opentelemetry-sdk
opentelemetry-semantic_conventions
opentelemetry-logs-api (0.5.0)
opentelemetry-instrumentation-active_job (0.13.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-base (0.27.0)
opentelemetry-api (~> 1.7)
opentelemetry-common (~> 0.21)
opentelemetry-logs-api (~> 0.6)
opentelemetry-metrics-api (~> 0.9)
opentelemetry-registry (~> 0.1)
opentelemetry-logs-api (0.7.0)
opentelemetry-api (~> 1.0)
opentelemetry-logs-sdk (0.6.1)
opentelemetry-api (~> 1.2)
opentelemetry-logs-api (~> 0.1)
opentelemetry-sdk (~> 1.3)
opentelemetry-metrics-api (0.10.0)
opentelemetry-api (~> 1.0)
opentelemetry-registry (0.6.3)
opentelemetry-api (~> 1.1)
opentelemetry-sdk (1.13.1)
Expand Down Expand Up @@ -288,13 +303,15 @@ PLATFORMS
x86_64-linux-musl

DEPENDENCIES
activejob (~> 7.1)
activesupport (~> 7.1)
commander (~> 5.0)
concurrent-ruby
irb
mcp (>= 1.4)
oj (~> 3.17.7)
opentelemetry-exporter-otlp-logs
opentelemetry-instrumentation-active_job
opentelemetry-logs-sdk (>= 0.6.0)
opentelemetry-sdk
posthog-ruby!
Expand All @@ -310,6 +327,7 @@ DEPENDENCIES
CHECKSUMS
actionpack (7.2.3.1) sha256=b66afe7f937273270cb63f03bde7af7ba850017867766e8848d06d3e12e1e4ca
actionview (7.2.3.1) sha256=de19b86843391762ac24a6287c30fbba11cd475fa4d4b664924d5fb7a2f1ff7c
activejob (7.2.3.1) sha256=0bc4227ce371b82da119cd27ed91e0deb9b744bbfa266b86e4bd8d1e2a8f6ed8
activesupport (7.2.3.1) sha256=11ebed516a43a0bb47346227a35ebae4d9427465a7c9eb197a03d5c8d283cb34
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
Expand All @@ -329,6 +347,7 @@ CHECKSUMS
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
erb (4.0.4.1) sha256=47e2a70922ba4cec8c23c2304ad7e581665c37a297cb9a22a860162c7727ee24
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427
google-protobuf (4.36.2) sha256=2f8daff50ae14aad36ab0ccdcf284065fa71d726c444efe114e6db82ffe62494
google-protobuf (4.36.2-aarch64-linux-gnu) sha256=c3279ec6eaa59f6ed6276eda2a33cffb4306471183ab79a332e698b8564f5169
google-protobuf (4.36.2-aarch64-linux-musl) sha256=0772c9dde1e6dce9a6b44d1d35c28c29adee99d9bd6aff5e21840daeb22161ce
Expand Down Expand Up @@ -364,8 +383,11 @@ CHECKSUMS
opentelemetry-api (1.11.1) sha256=d07f9987ea6240b3fd5ed5dd7450f93c58e403e0553e804069c23ae18ba61ff7
opentelemetry-common (0.25.1) sha256=38fe87c751e4e13fcd47a8290b4ad67bbedf76c4ed89aeced5ea5c620e66bfb1
opentelemetry-exporter-otlp-logs (0.5.1) sha256=68e7523bfae7ee107da571cc2c19e0a4873d2e7611613a3888a764ed44bcebec
opentelemetry-logs-api (0.5.0) sha256=81d00aaa3e66887e66f6ea6a3c931e3f1e60b11a3c7e547a54725afe1d5fbc93
opentelemetry-instrumentation-active_job (0.13.0) sha256=42da156924bbabdaca6268be1e46963a04829bd0b2b82fc6e368597a3525cde2
opentelemetry-instrumentation-base (0.27.0) sha256=e55edb82cd0b018e82e746be61fa36cb05b7b68b2fa37f0e4a96b923ad3d7735
opentelemetry-logs-api (0.7.0) sha256=53ea80e36b830bf068d6e44ec3776da7bf42f62ccc1d5a5944cc221999febe5d
opentelemetry-logs-sdk (0.6.1) sha256=7fd1eccc6793948acff93bbea0dd1aa809a8579cb8fcdea8e0a6580a981a21c2
opentelemetry-metrics-api (0.10.0) sha256=bbeeba7606886d7a918b3e4edeb96ac51eb8d3308630d610eee12be53836c755
opentelemetry-registry (0.6.3) sha256=568fa8eaea94394f29f4fce3f0339ab10c3bf6d6c8beed6b3cfee6fd37fcc608
opentelemetry-sdk (1.13.1) sha256=91c9cc315ac7ef30243549eeff8b27cd0d39bd02bdf9c9ae481ab96a1cec6754
opentelemetry-semantic_conventions (1.44.0) sha256=b09b8793422f13b01c08f7a6f29b5f511f47f37bcdb57c0513465a0464168513
Expand Down
4 changes: 2 additions & 2 deletions lib/posthog/internal/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,12 @@ def self.current=(context)
end
end

def self.with_context(data = nil, fresh: false, owner: UNBOUND_OWNER, **kwargs)
def self.with_context(data = nil, fresh: false, owner: UNBOUND_OWNER, enrich_current_span: true, **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, owner: owner)
SpanContext.apply_current
SpanContext.apply_current if enrich_current_span
yield
ensure
self.current = previous_context
Expand Down
8 changes: 7 additions & 1 deletion posthog-rails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,16 @@ Request context adds missing `sessionId` and `posthogDistinctId` attributes to a

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.
Existing canonical attributes take precedence. Missing IDs stay absent. Spans keep their captured IDs after the request ends. Separate threads and fibers 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.

## Active Job context

Set `config.auto_instrument_active_job = true` to copy the current session and distinct IDs into queued Active Job data. Each job runs with its saved IDs in a fresh context. Request properties are excluded. The context is restored after success, an error, or a retry. Older jobs without saved IDs run with empty context.

The existing job distinct ID extractor takes precedence over the saved ID. Rescue, discard, and terminal retry handlers keep this effective context, including when they enqueue child jobs. Integer and finite floating-point distinct IDs are stored as strings in job snapshots; session IDs must be strings. Events, optional logs, and application OpenTelemetry spans use the shared job context. Register `PostHog::ContextSpanProcessor` for child spans as described in the trace context guide. An existing job span receives IDs only when its public `messaging.system` is `active_job` and `messaging.message.id` matches the current job ID. Other active spans stay unchanged. This transfers PostHog IDs; it does not transfer a distributed trace or configure a tracing provider.

## PostHog Logs (optional)

`posthog-rails` can forward `Rails.logger` output to [PostHog Logs](https://posthog.com/docs/logs)
Expand Down
113 changes: 107 additions & 6 deletions posthog-rails/lib/posthog/rails/active_job.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# frozen_string_literal: true

require 'posthog/rails/parameter_filter'
require 'posthog/internal/context'
require 'posthog/rails/tracing_headers'

module PostHog
module Rails
Expand All @@ -10,6 +12,7 @@ module ActiveJobExtensions

def self.prepended(base)
base.extend(ClassMethods)
base.around_perform :with_posthog_job_context, prepend: true if base.respond_to?(:around_perform)
end

module ClassMethods
Expand All @@ -36,21 +39,119 @@ def posthog_distinct_id_proc
end
end

def perform_now
# @api private
def enqueue(...)
posthog_job_context if PostHog::Rails.config&.auto_instrument_active_job
super
rescue StandardError => e
# Capture the exception with job context
capture_job_exception(e)
raise
end

# @api private
def serialize
data = super
return data unless PostHog::Rails.config&.auto_instrument_active_job

data.merge('posthog_context' => posthog_job_context.dup)
end

# @api private
def deserialize(data)
super.tap { @posthog_job_context = copy_posthog_ids(data['posthog_context']) }
end

# @api private
def rescue_with_handler(...)
return super unless PostHog::Rails.config&.auto_instrument_active_job

Internal::Context.with_context(
@posthog_execution_context || posthog_job_context, fresh: true, enrich_current_span: false
) do
enrich_posthog_job_span
super
end
end

def perform_now
return super unless PostHog::Rails.config&.auto_instrument_active_job

posthog_job_context
@posthog_execution_context = nil
Internal::Context.with_context({}, fresh: true, owner: PostHog.client, enrich_current_span: false) do
super
rescue StandardError => e
# Capture the exception with job context
Internal::Context.with_context(
@posthog_execution_context || @posthog_job_context, fresh: true, enrich_current_span: false
) do
enrich_posthog_job_span
capture_job_exception(e)
end
raise
end
end

private

def copy_posthog_ids(data)
data = {} unless data.is_a?(Hash)
%w[distinct_id session_id].each_with_object({}) do |key, result|
value = data[key]
value = value.to_s if key == 'distinct_id' && (value.is_a?(Integer) || (value.is_a?(Float) && value.finite?))
value = TracingHeaders.sanitize_header_value(value)
result[key] = value.freeze if value
end.freeze
end

def posthog_job_context
@posthog_job_context ||= begin
context = Internal::Context.current
copy_posthog_ids('distinct_id' => context&.distinct_id, 'session_id' => context&.session_id)
end
end

def with_posthog_job_context(&block)
return yield unless PostHog::Rails.config&.auto_instrument_active_job

distinct_id = posthog_job_distinct_id
@posthog_execution_context = if distinct_id.nil?
posthog_job_context
else
posthog_job_context.merge('distinct_id' => distinct_id).freeze
end
Internal::Context.with_context(@posthog_execution_context, fresh: true, enrich_current_span: false) do
enrich_posthog_job_span
block.call
end
end

def enrich_posthog_job_span
return unless defined?(::OpenTelemetry::Trace)

span = ::OpenTelemetry::Trace.current_span
return unless span.recording? && span.respond_to?(:attributes)

attributes = span.attributes || {}
return unless attributes['messaging.system'] == 'active_job' && attributes['messaging.message.id'] == job_id

Internal::SpanContext.apply(span)
rescue StandardError
nil
end

def posthog_job_distinct_id
extract_distinct_id_from_job
rescue StandardError
nil
end

def capture_job_exception(exception)
return unless PostHog::Rails.config&.auto_instrument_active_job

# Build distinct_id from job arguments if possible
distinct_id = extract_distinct_id_from_job
distinct_id = if @posthog_execution_context
@posthog_execution_context['distinct_id']
else
extract_distinct_id_from_job
end

properties = {
'$exception_source' => 'active_job',
Expand Down
61 changes: 61 additions & 0 deletions spec/fixtures/active_job_span_context.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# frozen_string_literal: true

require 'rails'
require 'active_job'
require 'posthog'
require 'posthog/rails'
require 'posthog/otel/context_span_processor'
require 'opentelemetry-sdk'
require 'opentelemetry-instrumentation-active_job'

PostHog::Rails.config = PostHog::Rails::Configuration.new
PostHog::Rails.config.auto_instrument_active_job = true
PostHog::Logging.logger = Logger.new(File::NULL)
PostHog.client = PostHog::Client.new(api_key: 'phc_fixture', test_mode: true)
exporter = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new
OpenTelemetry::SDK.configure do |config|
config.add_span_processor(PostHog::ContextSpanProcessor.new)
config.add_span_processor(OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(exporter))
config.use 'OpenTelemetry::Instrumentation::ActiveJob'
end

class SpanContextFixtureJob < ActiveJob::Base
prepend PostHog::Rails::ActiveJobExtensions

self.queue_adapter = :test
self.logger = Logger.new(File::NULL)

def perform
OpenTelemetry.tracer_provider.tracer('job-fixture').in_span('child') do |span|
span.set_attribute('job.performed', true)
end
end
end

context = PostHog.const_get(:Internal).const_get(:Context)
payloads = %w[first second].map do |identity|
context.with_context(distinct_id: identity, session_id: "session-#{identity}") do
SpanContextFixtureJob.perform_later
end
JSON.parse(JSON.generate(SpanContextFixtureJob.queue_adapter.enqueued_jobs.last))
end
exporter.reset
tracer = OpenTelemetry.tracer_provider.tracer('job-fixture')
tracer.in_span('caller') { payloads.each { |payload| ActiveJob::Base.execute(payload) } }
spans = exporter.finished_spans
raise 'Caller span changed' unless (spans.find { |span| span.name == 'caller' }.attributes || {}).empty?

payloads.zip(%w[first second]).each do |payload, identity|
job_span = spans.find { |span| span.attributes&.fetch('messaging.message.id', nil) == payload['job_id'] }
raise 'Official job span missing' unless job_span
raise 'Wrong instrumentation system' unless job_span.attributes['messaging.system'] == 'active_job'
raise 'Job identity missing' unless job_span.attributes['posthogDistinctId'] == identity
raise 'Job session missing' unless job_span.attributes['sessionId'] == "session-#{identity}"

child = spans.find { |span| span.name == 'child' && span.parent_span_id == job_span.span_id }
raise 'Child identity missing' unless child&.attributes&.fetch('posthogDistinctId', nil) == identity
end
raise 'Job scope leaked' unless context.current.nil?

OpenTelemetry.tracer_provider.shutdown
puts 'Official Active Job spans and children keep their IDs; caller and outer context stay unchanged'
Loading
Loading