From 444918afd9c5b2164268c366f2e4a85251c64d20 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 17 Oct 2026 16:07:22 +0100 Subject: [PATCH 1/4] feat(rails): carry request context into Active Job Generated-By: PostHog Desktop Task-Id: 5681be44-4986-48db-b140-1bda88f75f96 --- .changeset/tired-trees-dress.md | 5 + Gemfile | 1 + Gemfile.lock | 8 + gemfiles/otel.gemfile.lock | 8 + posthog-rails/README.md | 8 +- posthog-rails/lib/posthog/rails/active_job.rb | 79 ++++- spec/posthog/rails/active_job_context_spec.rb | 294 ++++++++++++++++++ 7 files changed, 396 insertions(+), 7 deletions(-) create mode 100644 .changeset/tired-trees-dress.md create mode 100644 spec/posthog/rails/active_job_context_spec.rb diff --git a/.changeset/tired-trees-dress.md b/.changeset/tired-trees-dress.md new file mode 100644 index 0000000..b170bb9 --- /dev/null +++ b/.changeset/tired-trees-dress.md @@ -0,0 +1,5 @@ +--- +"posthog-rails": minor +--- + +Carry session and person IDs from requests into Active Job execution when automatic job instrumentation is enabled. diff --git a/Gemfile b/Gemfile index 03d8ba4..eece210 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/Gemfile.lock b/Gemfile.lock index b9785f2..fa7fda0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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 @@ -236,6 +241,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + activejob (~> 7.1) activesupport (~> 7.1) commander (~> 5.0) concurrent-ruby @@ -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 @@ -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 diff --git a/gemfiles/otel.gemfile.lock b/gemfiles/otel.gemfile.lock index 3fd215e..2272343 100644 --- a/gemfiles/otel.gemfile.lock +++ b/gemfiles/otel.gemfile.lock @@ -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) @@ -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) @@ -288,6 +293,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + activejob (~> 7.1) activesupport (~> 7.1) commander (~> 5.0) concurrent-ruby @@ -310,6 +316,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 @@ -329,6 +336,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 diff --git a/posthog-rails/README.md b/posthog-rails/README.md index 2e65592..34c993b 100644 --- a/posthog-rails/README.md +++ b/posthog-rails/README.md @@ -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. 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. 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) diff --git a/posthog-rails/lib/posthog/rails/active_job.rb b/posthog-rails/lib/posthog/rails/active_job.rb index b621ae8..11a3a28 100644 --- a/posthog-rails/lib/posthog/rails/active_job.rb +++ b/posthog-rails/lib/posthog/rails/active_job.rb @@ -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 @@ -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 @@ -36,21 +39,85 @@ 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 + + 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) do + super + rescue StandardError => e + # Capture the exception with job context + Internal::Context.with_context(@posthog_execution_context || @posthog_job_context, fresh: true) do + 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 = TracingHeaders.sanitize_header_value(data[key]) + 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, &block) + 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', diff --git a/spec/posthog/rails/active_job_context_spec.rb b/spec/posthog/rails/active_job_context_spec.rb new file mode 100644 index 0000000..85698ad --- /dev/null +++ b/spec/posthog/rails/active_job_context_spec.rb @@ -0,0 +1,294 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rails' +require 'rails/railtie' +require 'active_job' + +$LOAD_PATH.unshift File.expand_path('../../../posthog-rails/lib', __dir__) +require 'posthog/rails' + +RSpec.describe 'Active Job context' do + let(:context_class) { PostHog.const_get(:Internal).const_get(:Context) } + let(:client) { PostHog::Client.new(api_key: API_KEY, test_mode: true) } + let(:observations) { [] } + let(:exception_contexts) { [] } + let(:job_class) do + stub_const('PostHogContextTestJob', Class.new(ActiveJob::Base) do + prepend PostHog::Rails::ActiveJobExtensions + + class_attribute :observer + + def perform(mode = 'success') + self.class.observer.call(self) + raise 'job failed' if mode == 'failure' + end + end) + PostHogContextTestJob.queue_adapter = :test + PostHogContextTestJob.logger = Logger.new(File::NULL) + PostHogContextTestJob.observer = lambda do |job| + current = context_class.current + observations << [current&.distinct_id, current&.session_id, current&.properties] + client.capture(event: 'job_ran') + raise 'retry job' if job.arguments.first == 'retry' && job.executions == 1 + end + PostHogContextTestJob + end + + around do |example| + previous_config = PostHog::Rails.config + PostHog::Rails.config = PostHog::Rails::Configuration.new + PostHog::Rails.config.auto_instrument_active_job = true + example.run + ensure + PostHog::Rails.config = previous_config + client.shutdown + end + + before do + allow(PostHog).to receive(:capture_exception) do + current = context_class.current + exception_contexts << [current&.distinct_id, current&.session_id] + end + end + + def snapshot_job(mode = 'success') + context_class.with_context(distinct_id: 'browser-person', session_id: 'browser-session', + properties: { 'request_only' => 'excluded' }) do + job_class.perform_later(mode) + end + JSON.parse(JSON.generate(job_class.queue_adapter.enqueued_jobs.last)) + end + + [false, true].each do |fails| + it "restores enqueued IDs and clears worker scope after #{fails ? 'failure' : 'success'}" do + payload = snapshot_job(fails ? 'failure' : 'success') + context_class.with_context(distinct_id: 'worker-person', session_id: 'worker-session') do + outer = context_class.current + invoke = -> { ActiveJob::Base.execute(payload) } + fails ? expect(&invoke).to(raise_error('job failed')) : invoke.call + expect(context_class.current).to equal(outer) + end + expect(observations).to eq([['browser-person', 'browser-session', { '$session_id' => 'browser-session' }]]) + event = client.dequeue_last_message + expect(event[:distinct_id]).to eq('browser-person') + expect(event[:properties]['$session_id']).to eq('browser-session') + expect(event[:properties]).not_to have_key('request_only') + expect(context_class.current).to be_nil + expect(PostHog).to have_received(:capture_exception).exactly(fails ? 1 : 0).times + expect(exception_contexts).to eq(fails ? [%w[browser-person browser-session]] : []) + end + end + + it 'copies the enqueue snapshot and excludes request properties from the serialized payload' do + person = +'browser-person' + session = +'browser-session' + job = nil + context_class.with_context(distinct_id: person, session_id: session, properties: { 'private' => 'excluded' }) do + job = job_class.perform_later + person.replace('changed-person') + session.replace('changed-session') + end + payload = JSON.parse(JSON.generate(job.serialize)) + expect(payload['posthog_context']).to eq('distinct_id' => 'browser-person', 'session_id' => 'browser-session') + ActiveJob::Base.execute(payload) + expect(observations.first.first(2)).to eq(%w[browser-person browser-session]) + end + + it 'takes its snapshot at enqueue before an adapter serializes the job later' do + stub_const('DeferredContextTestAdapter', Class.new(ActiveJob::QueueAdapters::TestAdapter) do + attr_reader :job + + def enqueue(job) + @job = job + end + end) + adapter = DeferredContextTestAdapter.new + job_class.queue_adapter = adapter + context_class.with_context(distinct_id: 'enqueue-person', session_id: 'enqueue-session') do + job_class.perform_later + end + context_class.with_context(distinct_id: 'later-person', session_id: 'later-session') do + payload = JSON.parse(JSON.generate(adapter.job.serialize)) + ActiveJob::Base.execute(payload) + end + expect(observations.first.first(2)).to eq(%w[enqueue-person enqueue-session]) + end + + [nil, {}, { 'session_id' => 12, 'distinct_id' => [] }].each do |saved| + it "clears stale worker identity for a missing or invalid payload #{saved.inspect}" do + payload = job_class.new.serialize + saved.nil? ? payload.delete('posthog_context') : payload['posthog_context'] = saved + context_class.with_context(distinct_id: 'stale-person', session_id: 'stale-session') do + ActiveJob::Base.execute(payload) + end + expect(observations.first.first(2)).to eq([nil, nil]) + end + end + + it 'uses caller context for direct perform_now and restores it' do + context_class.with_context(distinct_id: 'direct-person', session_id: 'direct-session') do + outer = context_class.current + job_class.perform_now + expect(context_class.current).to equal(outer) + end + expect(observations.first.first(2)).to eq(%w[direct-person direct-session]) + end + + it 'uses the explicit job distinct ID extractor after argument deserialization' do + job_class.posthog_distinct_id { |data| data.fetch('user_id') } + payload = nil + context_class.with_context(distinct_id: 'browser-person', session_id: 'browser-session') do + job_class.perform_later('user_id' => 'application-person') + payload = JSON.parse(JSON.generate(job_class.queue_adapter.enqueued_jobs.last)) + end + ActiveJob::Base.execute(payload) + expect(observations.first.first(2)).to eq(%w[application-person browser-session]) + end + + it 'keeps saved identity on handled retries and captures only the final unhandled exception' do + job_class.retry_on RuntimeError, wait: 0, attempts: 2 + payload = snapshot_job('retry') + ActiveJob::Base.execute(payload) + expect(PostHog).not_to have_received(:capture_exception) + retry_payload = JSON.parse(JSON.generate(job_class.queue_adapter.enqueued_jobs.last)) + context_class.with_context(distinct_id: 'unrelated-person', session_id: 'unrelated-session') do + ActiveJob::Base.execute(retry_payload) + end + expect(observations.map { |row| row.first(2) }).to eq([%w[browser-person browser-session]] * 2) + expect(PostHog).not_to have_received(:capture_exception) + end + + [false, true].each do |discard| + it "captures only unhandled terminal errors, discard=#{discard}" do + job_class.retry_on RuntimeError, wait: 0, attempts: 1 + job_class.discard_on RuntimeError if discard + invoke = -> { ActiveJob::Base.execute(snapshot_job('failure')) } + discard ? invoke.call : expect(&invoke).to(raise_error('job failed')) + expect(PostHog).to have_received(:capture_exception).exactly(discard ? 0 : 1).times + end + end + + it 'supports bulk enqueue through the adapter serializer' do + jobs = [job_class.new, job_class.new] + context_class.with_context(distinct_id: 'bulk-person', session_id: 'bulk-session') do + ActiveJob.perform_all_later(jobs) + end + job_class.queue_adapter.enqueued_jobs.each do |payload| + ActiveJob::Base.execute(JSON.parse(JSON.generate(payload))) + end + expect(observations.map { |row| row.first(2) }).to eq([%w[bulk-person bulk-session]] * 2) + end + + it 'captures deserialization errors with saved IDs and restores the worker context' do + payload = snapshot_job + payload['arguments'] = [{ '_aj_serialized' => 'MissingJobArgumentSerializer' }] + context_class.with_context(distinct_id: 'worker-person', session_id: 'worker-session') do + outer = context_class.current + expect { ActiveJob::Base.execute(payload) }.to raise_error(ActiveJob::DeserializationError) + expect(context_class.current).to equal(outer) + end + expect(observations).to be_empty + expect(exception_contexts).to eq([%w[browser-person browser-session]]) + end + + it 'keeps concurrent workers and a later empty job separate' do + payloads = %w[first second].map do |person| + context_class.with_context(distinct_id: person, session_id: "session-#{person}") do + job_class.perform_later + end + JSON.parse(JSON.generate(job_class.queue_adapter.enqueued_jobs.last)) + end + started = Queue.new + proceed = Queue.new + original_observer = job_class.observer + job_class.observer = lambda do |job| + started << true + proceed.pop + original_observer.call(job) + end + threads = payloads.map do |payload| + Thread.new do + ActiveJob::Base.execute(payload) + expect(context_class.current).to be_nil + end + end + 2.times { started.pop } + 2.times { proceed << true } + threads.each(&:value) + job_class.observer = original_observer + ActiveJob::Base.execute(job_class.new.serialize) + expect(observations.map { |row| row.first(2) }).to contain_exactly( + %w[first session-first], %w[second session-second], [nil, nil] + ) + end + + it 'keeps job execution working when its identity extractor fails' do + job_class.posthog_distinct_id { raise 'identity lookup failed' } + ActiveJob::Base.execute(snapshot_job) + expect(observations.first.first(2)).to eq(%w[browser-person browser-session]) + end + + it 'does not let explicit event values change the saved job context' do + original_observer = job_class.observer + job_class.observer = lambda do |job| + original_observer.call(job) + client.capture(event: 'explicit_job_event', distinct_id: 'explicit-person', + properties: { '$session_id' => 'explicit-session' }) + end + ActiveJob::Base.execute(snapshot_job) + client.dequeue_last_message + event = client.dequeue_last_message + expect(event[:distinct_id]).to eq('explicit-person') + expect(event[:properties]['$session_id']).to eq('explicit-session') + expect(observations.first.first(2)).to eq(%w[browser-person browser-session]) + end + + it 'adds job IDs to existing and child SDK spans and optional forwarded logs' do + begin + require 'opentelemetry-sdk' + require 'opentelemetry-logs-sdk' + rescue LoadError + skip 'Install the gemfiles/otel.gemfile bundle to run span and log integration' + end + require 'posthog/otel/context_span_processor' + require 'posthog/rails/logs/appender' + spans = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new + provider = OpenTelemetry::SDK::Trace::TracerProvider.new + provider.add_span_processor(PostHog::ContextSpanProcessor.new) + provider.add_span_processor(OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(spans)) + tracer = provider.tracer('active-job-test') + logs = OpenTelemetry::SDK::Logs::Export::InMemoryLogRecordExporter.new + logs_provider = OpenTelemetry::SDK::Logs::LoggerProvider.new + logs_provider.add_log_record_processor(OpenTelemetry::SDK::Logs::Export::SimpleLogRecordProcessor.new(logs)) + appender = PostHog::Rails::Logs::Appender.new(logs_provider.logger(name: 'active-job-test')) + job_class.posthog_distinct_id { 'application-person' } + job_class.observer = lambda do |_job| + tracer.in_span('child', attributes: { 'sessionId' => 'explicit-session' }) { appender.info('job ran') } + end + payload = snapshot_job + tracer.in_span('worker') { ActiveJob::Base.execute(payload) } + by_name = spans.finished_spans.to_h { |span| [span.name, span] } + expect(by_name['worker'].attributes).to include('posthogDistinctId' => 'application-person', + 'sessionId' => 'browser-session') + expect(by_name['child'].attributes).to include('posthogDistinctId' => 'application-person', + 'sessionId' => 'explicit-session') + record = logs.emitted_log_records.first + expect(record.attributes).to include('posthogDistinctId' => 'application-person', 'sessionId' => 'browser-session') + expect(record.trace_id).to eq(by_name['child'].trace_id) + expect(record.span_id).to eq(by_name['child'].span_id) + expect(context_class.current).to be_nil + ensure + provider&.shutdown + logs_provider&.shutdown + end + + it 'leaves serialization and execution unchanged when the integration is disabled' do + PostHog::Rails.config.auto_instrument_active_job = false + context_class.with_context(distinct_id: 'application-person', session_id: 'application-session') do + expect(job_class.new.serialize).not_to have_key('posthog_context') + job_class.perform_now + end + expect(observations.first.first(2)).to eq(%w[application-person application-session]) + end +end From 7fcc49c8327e7b74676c411a43a4a3b4107a2e05 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 17 Oct 2026 21:28:28 +0100 Subject: [PATCH 2/4] fix(rails): preserve caller spans during active job execution Generated-By: PostHog Desktop Task-Id: 5681be44-4986-48db-b140-1bda88f75f96 --- .changeset/silent-games-cheer.md | 5 ++ gemfiles/otel.gemfile | 1 + gemfiles/otel.gemfile.lock | 18 +++++- lib/posthog/internal/context.rb | 4 +- posthog-rails/README.md | 2 +- posthog-rails/lib/posthog/rails/active_job.rb | 26 ++++++++- spec/fixtures/active_job_span_context.rb | 58 +++++++++++++++++++ spec/posthog/rails/active_job_context_spec.rb | 57 +++++++++++++++++- 8 files changed, 162 insertions(+), 9 deletions(-) create mode 100644 .changeset/silent-games-cheer.md create mode 100644 spec/fixtures/active_job_span_context.rb diff --git a/.changeset/silent-games-cheer.md b/.changeset/silent-games-cheer.md new file mode 100644 index 0000000..2e86c82 --- /dev/null +++ b/.changeset/silent-games-cheer.md @@ -0,0 +1,5 @@ +--- +"posthog-ruby": patch +--- + +Allow framework job scopes to preserve unrelated caller spans while retaining event and child-span context. diff --git a/gemfiles/otel.gemfile b/gemfiles/otel.gemfile index 0eb2f6e..7ae18b3 100644 --- a/gemfiles/otel.gemfile +++ b/gemfiles/otel.gemfile @@ -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' diff --git a/gemfiles/otel.gemfile.lock b/gemfiles/otel.gemfile.lock index 2272343..6724596 100644 --- a/gemfiles/otel.gemfile.lock +++ b/gemfiles/otel.gemfile.lock @@ -150,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) @@ -301,6 +311,7 @@ DEPENDENCIES 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! @@ -372,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 diff --git a/lib/posthog/internal/context.rb b/lib/posthog/internal/context.rb index c3ed447..f0562a4 100644 --- a/lib/posthog/internal/context.rb +++ b/lib/posthog/internal/context.rb @@ -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 diff --git a/posthog-rails/README.md b/posthog-rails/README.md index 34c993b..544ea19 100644 --- a/posthog-rails/README.md +++ b/posthog-rails/README.md @@ -23,7 +23,7 @@ See the [Rails trace context guide](https://posthog.com/docs/libraries/ruby-on-r 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. 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. This transfers PostHog IDs; it does not transfer a distributed trace or configure a tracing provider. +The existing job distinct ID extractor takes precedence over the saved ID. 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) diff --git a/posthog-rails/lib/posthog/rails/active_job.rb b/posthog-rails/lib/posthog/rails/active_job.rb index 11a3a28..d309888 100644 --- a/posthog-rails/lib/posthog/rails/active_job.rb +++ b/posthog-rails/lib/posthog/rails/active_job.rb @@ -63,11 +63,14 @@ def perform_now posthog_job_context @posthog_execution_context = nil - Internal::Context.with_context({}, fresh: true) do + Internal::Context.with_context({}, fresh: true, 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) do + 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 @@ -100,7 +103,24 @@ def with_posthog_job_context(&block) else posthog_job_context.merge('distinct_id' => distinct_id).freeze end - Internal::Context.with_context(@posthog_execution_context, fresh: true, &block) + 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 diff --git a/spec/fixtures/active_job_span_context.rb b/spec/fixtures/active_job_span_context.rb new file mode 100644 index 0000000..6a67546 --- /dev/null +++ b/spec/fixtures/active_job_span_context.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails' +require 'active_job' +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 +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' diff --git a/spec/posthog/rails/active_job_context_spec.rb b/spec/posthog/rails/active_job_context_spec.rb index 85698ad..995201b 100644 --- a/spec/posthog/rails/active_job_context_spec.rb +++ b/spec/posthog/rails/active_job_context_spec.rb @@ -4,6 +4,7 @@ require 'rails' require 'rails/railtie' require 'active_job' +require 'open3' $LOAD_PATH.unshift File.expand_path('../../../posthog-rails/lib', __dir__) require 'posthog/rails' @@ -244,6 +245,47 @@ def enqueue(job) expect(observations.first.first(2)).to eq(%w[browser-person browser-session]) end + %w[success failure].product(%w[none wrong-id wrong-system]).each do |outcome, marker| + it "keeps an unrelated caller unchanged across two #{outcome} jobs with #{marker} markers" do + begin + require 'opentelemetry-sdk' + rescue LoadError + skip 'Install the optional OpenTelemetry bundle' + end + require 'posthog/otel/context_span_processor' + spans = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new + provider = OpenTelemetry::SDK::Trace::TracerProvider.new + provider.add_span_processor(PostHog::ContextSpanProcessor.new) + provider.add_span_processor(OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(spans)) + tracer = provider.tracer('job-caller') + first = snapshot_job(outcome) + second = snapshot_job(outcome) + second['posthog_context'] = { 'distinct_id' => 'second-person', 'session_id' => 'second-session' } + original_observer = job_class.observer + job_class.observer = ->(job) { tracer.in_span('child') { original_observer.call(job) } } + attributes = case marker + when 'wrong-id' + { 'messaging.system' => 'active_job', 'messaging.message.id' => 'another-job' } + when 'wrong-system' + { 'messaging.system' => 'other', 'messaging.message.id' => first['job_id'] } + else + {} + end + tracer.in_span('caller', attributes: attributes) do + [first, second].each do |payload| + invoke = -> { ActiveJob::Base.execute(payload) } + outcome == 'failure' ? expect(&invoke).to(raise_error('job failed')) : invoke.call + end + end + expect(spans.finished_spans.find { |span| span.name == 'caller' }.attributes || {}).to eq(attributes) + children = spans.finished_spans.select { |span| span.name == 'child' } + expect(children.map { |span| span.attributes['posthogDistinctId'] }).to eq(%w[browser-person second-person]) + expect(children.map { |span| span.attributes['sessionId'] }).to eq(%w[browser-session second-session]) + ensure + provider&.shutdown + end + end + it 'adds job IDs to existing and child SDK spans and optional forwarded logs' do begin require 'opentelemetry-sdk' @@ -267,7 +309,10 @@ def enqueue(job) tracer.in_span('child', attributes: { 'sessionId' => 'explicit-session' }) { appender.info('job ran') } end payload = snapshot_job - tracer.in_span('worker') { ActiveJob::Base.execute(payload) } + tracer.in_span('worker', attributes: { 'messaging.system' => 'active_job', + 'messaging.message.id' => payload['job_id'] }) do + ActiveJob::Base.execute(payload) + end by_name = spans.finished_spans.to_h { |span| [span.name, span] } expect(by_name['worker'].attributes).to include('posthogDistinctId' => 'application-person', 'sessionId' => 'browser-session') @@ -283,6 +328,16 @@ def enqueue(job) logs_provider&.shutdown end + it 'enriches job spans from the official OpenTelemetry instrumentation' do + unless Gem.loaded_specs.key?('opentelemetry-instrumentation-active_job') + skip 'Install the optional OpenTelemetry bundle' + end + + fixture = File.expand_path('../../fixtures/active_job_span_context.rb', __dir__) + output, status = Open3.capture2e(RbConfig.ruby, '-Ilib', '-Iposthog-rails/lib', fixture) + expect(status.success?).to be(true), output + end + it 'leaves serialization and execution unchanged when the integration is disabled' do PostHog::Rails.config.auto_instrument_active_job = false context_class.with_context(distinct_id: 'application-person', session_id: 'application-session') do From b497a30f695642c1f8eb308b7a776294b3af2be6 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 17 Oct 2026 23:37:35 +0100 Subject: [PATCH 3/4] fix(rails): retain job identity in exception handlers Generated-By: PostHog Desktop Task-Id: 5681be44-4986-48db-b140-1bda88f75f96 --- posthog-rails/README.md | 2 +- posthog-rails/lib/posthog/rails/active_job.rb | 16 ++++- spec/posthog/rails/active_job_context_spec.rb | 64 ++++++++++++++++++- 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/posthog-rails/README.md b/posthog-rails/README.md index 544ea19..ad4d07c 100644 --- a/posthog-rails/README.md +++ b/posthog-rails/README.md @@ -23,7 +23,7 @@ See the [Rails trace context guide](https://posthog.com/docs/libraries/ruby-on-r 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. 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. +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) diff --git a/posthog-rails/lib/posthog/rails/active_job.rb b/posthog-rails/lib/posthog/rails/active_job.rb index d309888..aeb3c04 100644 --- a/posthog-rails/lib/posthog/rails/active_job.rb +++ b/posthog-rails/lib/posthog/rails/active_job.rb @@ -58,6 +58,18 @@ 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 @@ -82,7 +94,9 @@ def perform_now def copy_posthog_ids(data) data = {} unless data.is_a?(Hash) %w[distinct_id session_id].each_with_object({}) do |key, result| - value = TracingHeaders.sanitize_header_value(data[key]) + 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 diff --git a/spec/posthog/rails/active_job_context_spec.rb b/spec/posthog/rails/active_job_context_spec.rb index 995201b..b647d75 100644 --- a/spec/posthog/rails/active_job_context_spec.rb +++ b/spec/posthog/rails/active_job_context_spec.rb @@ -116,7 +116,8 @@ def enqueue(job) expect(observations.first.first(2)).to eq(%w[enqueue-person enqueue-session]) end - [nil, {}, { 'session_id' => 12, 'distinct_id' => [] }].each do |saved| + [nil, {}, { 'session_id' => 12, 'distinct_id' => [] }, + { 'distinct_id' => Float::INFINITY }, { 'distinct_id' => Float::NAN }].each do |saved| it "clears stale worker identity for a missing or invalid payload #{saved.inspect}" do payload = job_class.new.serialize saved.nil? ? payload.delete('posthog_context') : payload['posthog_context'] = saved @@ -170,6 +171,67 @@ def enqueue(job) end end + %i[rescue discard terminal_retry].each do |handler| + [nil, 'application-person', 123].each do |extracted_id| + it "keeps effective identity in #{handler} handlers and child jobs with extractor #{extracted_id.inspect}" do + job_class.posthog_distinct_id { extracted_id } unless extracted_id.nil? + child_class = stub_const('PostHogChildContextTestJob', Class.new(ActiveJob::Base) do + prepend PostHog::Rails::ActiveJobExtensions + + def perform; end + end) + child_class.queue_adapter = :test + child_class.logger = Logger.new(File::NULL) + handler_contexts = [] + handler_context_class = context_class + handler_client = client + callback = lambda do |*_args| + current = handler_context_class.current + handler_contexts << [current&.distinct_id, current&.session_id] + handler_client.capture(event: 'job_handled') + child_class.perform_later + end + case handler + when :rescue then job_class.rescue_from(RuntimeError, &callback) + when :discard then job_class.discard_on(RuntimeError, &callback) + when :terminal_retry then job_class.retry_on(RuntimeError, attempts: 1, &callback) + end + payload = snapshot_job('failure') + context_class.with_context(distinct_id: 'worker-person', session_id: 'worker-session') do + outer = context_class.current + ActiveJob::Base.execute(payload) + expect(context_class.current).to equal(outer) + end + effective_id = extracted_id || 'browser-person' + expect(handler_contexts).to eq([[effective_id, 'browser-session']]) + event = client.dequeue_last_message + expect(event[:distinct_id]).to eq(effective_id) + expect(event[:properties]['$session_id']).to eq('browser-session') + expect(child_class.queue_adapter.enqueued_jobs.last['posthog_context']).to eq( + 'distinct_id' => effective_id.to_s, 'session_id' => 'browser-session' + ) + expect(PostHog).not_to have_received(:capture_exception) + expect(context_class.current).to be_nil + end + end + end + + [123, 1.5].each do |distinct_id| + it "copies numeric distinct ID #{distinct_id} into child jobs during perform" do + job_class.posthog_distinct_id { distinct_id } + original_observer = job_class.observer + job_class.observer = lambda do |job| + original_observer.call(job) + job_class.perform_later('child') unless job.arguments.first == 'child' + end + ActiveJob::Base.execute(snapshot_job) + child_payload = JSON.parse(JSON.generate(job_class.queue_adapter.enqueued_jobs.last)) + expect(child_payload['posthog_context']).to eq( + 'distinct_id' => distinct_id.to_s, 'session_id' => 'browser-session' + ) + end + end + it 'supports bulk enqueue through the adapter serializer' do jobs = [job_class.new, job_class.new] context_class.with_context(distinct_id: 'bulk-person', session_id: 'bulk-session') do From 98c5c85482dd8fe8aaa8a59a106eeffa460ffae5 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sun, 18 Oct 2026 11:14:55 +0100 Subject: [PATCH 4/4] fix(rails): bind active job span context to the worker client Generated-By: PostHog Desktop Task-Id: bfd18848-e498-4352-a58a-b9ba85de4198 --- posthog-rails/lib/posthog/rails/active_job.rb | 2 +- spec/fixtures/active_job_span_context.rb | 3 ++ spec/posthog/rails/active_job_context_spec.rb | 37 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/posthog-rails/lib/posthog/rails/active_job.rb b/posthog-rails/lib/posthog/rails/active_job.rb index aeb3c04..71fdd93 100644 --- a/posthog-rails/lib/posthog/rails/active_job.rb +++ b/posthog-rails/lib/posthog/rails/active_job.rb @@ -75,7 +75,7 @@ def perform_now posthog_job_context @posthog_execution_context = nil - Internal::Context.with_context({}, fresh: true, enrich_current_span: false) do + Internal::Context.with_context({}, fresh: true, owner: PostHog.client, enrich_current_span: false) do super rescue StandardError => e # Capture the exception with job context diff --git a/spec/fixtures/active_job_span_context.rb b/spec/fixtures/active_job_span_context.rb index 6a67546..b12289c 100644 --- a/spec/fixtures/active_job_span_context.rb +++ b/spec/fixtures/active_job_span_context.rb @@ -2,6 +2,7 @@ require 'rails' require 'active_job' +require 'posthog' require 'posthog/rails' require 'posthog/otel/context_span_processor' require 'opentelemetry-sdk' @@ -9,6 +10,8 @@ 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) diff --git a/spec/posthog/rails/active_job_context_spec.rb b/spec/posthog/rails/active_job_context_spec.rb index b647d75..5b169d4 100644 --- a/spec/posthog/rails/active_job_context_spec.rb +++ b/spec/posthog/rails/active_job_context_spec.rb @@ -38,15 +38,19 @@ def perform(mode = 'success') around do |example| previous_config = PostHog::Rails.config + previous_client = PostHog.client PostHog::Rails.config = PostHog::Rails::Configuration.new PostHog::Rails.config.auto_instrument_active_job = true example.run ensure PostHog::Rails.config = previous_config + PostHog.client&.shutdown unless PostHog.client.equal?(client) + PostHog.client = previous_client client.shutdown end before do + PostHog.client = client allow(PostHog).to receive(:capture_exception) do current = context_class.current exception_contexts << [current&.distinct_id, current&.session_id] @@ -390,6 +394,39 @@ def perform; end logs_provider&.shutdown end + [ + ['no facade client', -> {}], + ['a client without an API key', -> { PostHog::Client.new(api_key: nil, silence_disabled_client_error: true) }], + ['a client with a blank API key', -> { PostHog::Client.new(api_key: ' ', silence_disabled_client_error: true) }] + ].each do |owner_name, build_owner| + it "adds no job IDs to spans with #{owner_name}" do + begin + require 'opentelemetry-sdk' + rescue LoadError + skip 'Install the gemfiles/otel.gemfile bundle to run span integration' + end + require 'posthog/otel/context_span_processor' + PostHog.client = build_owner.call + spans = OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new + provider = OpenTelemetry::SDK::Trace::TracerProvider.new + provider.add_span_processor(PostHog::ContextSpanProcessor.new) + provider.add_span_processor(OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(spans)) + tracer = provider.tracer('active-job-test') + job_class.observer = ->(_job) { tracer.in_span('child') { nil } } + payload = snapshot_job + tracer.in_span('worker', attributes: { 'messaging.system' => 'active_job', + 'messaging.message.id' => payload['job_id'] }) do + ActiveJob::Base.execute(payload) + end + spans.finished_spans.each do |span| + expect(span.attributes.keys).not_to include('posthogDistinctId', 'sessionId') + end + expect(spans.finished_spans.map(&:name)).to contain_exactly('worker', 'child') + ensure + provider&.shutdown + end + end + it 'enriches job spans from the official OpenTelemetry instrumentation' do unless Gem.loaded_specs.key?('opentelemetry-instrumentation-active_job') skip 'Install the optional OpenTelemetry bundle'