rails-otel-context 0.8.5 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c77108c1ddfab7776d31052f6633e7ec132aafb415f682099f571e5c4758db89
4
- data.tar.gz: 262506de5a8bc5a4bbfa74341bdb79113fd54f4d0fe6d247c51cb35db395b006
3
+ metadata.gz: 592224b44e9a4b7b72a85624dbfea6b05717b629ff9e707a4975cfc6db276b18
4
+ data.tar.gz: '0757947daefb592c0ef3e63c4d2396fb049461224c244aa145d51218c9f12f28'
5
5
  SHA512:
6
- metadata.gz: 6dd47b5fcaea795a44e9e2db032fd77ccfddb97bef173be6ff1c19499e52e5ffbe26ff3b8a9ce8a5d4bf145f2f2aa58cd115aac04ac7ebf166d6dc98d0401ae6
7
- data.tar.gz: 5ac0a1d07185865a92f79bab9895b04f5405b7f4a4978f6ded96878ad1b4bb0ed76c093bcd1a7ab912f5c1935f6989fb692fc4dd9d9000a2f211b294c777123d
6
+ metadata.gz: 1a695932a41348bf86b042b9535b95c9423161ac33873af45539386d3602225de51d430d9d01ac979736742a6cc79377a335d1f2dc07f93281cbdbaf71cfb89d
7
+ data.tar.gz: defbc699fa36e0ad33b907965ee6bb4cc47b7190d82acebede64f395bc25534bc6710b59de621f3693e7d33e82be705aa96ad1db95c101dfefdf3efd779089c1
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![CI](https://github.com/last9/rails-otel-context/actions/workflows/ci.yml/badge.svg)](https://github.com/last9/rails-otel-context/actions/workflows/ci.yml)
4
4
  [![Gem Version](https://img.shields.io/gem/v/rails-otel-context)](https://rubygems.org/gems/rails-otel-context)
5
5
 
6
- OpenTelemetry spans for Rails know a lot about your database. They know the SQL. They know how long it took. What they don't know is *which code fired that query* — the model, the scope, the controller, the line number. This gem fixes that.
6
+ OpenTelemetry spans for Rails know a lot about your database. They know the SQL. They know how long it took. What they don't know is *which code fired that query* — the service object, the scope, the job, the line number. This gem fixes that.
7
7
 
8
8
  ## Before and after
9
9
 
@@ -26,111 +26,162 @@ With this gem:
26
26
  "db.system": "postgresql",
27
27
  "db.statement": "SELECT * FROM transactions WHERE ...",
28
28
  "duration_ms": 450,
29
- "code.activerecord.model": "Transaction",
29
+ "code.activerecord.model": "Transaction",
30
30
  "code.activerecord.method": "Load",
31
- "code.activerecord.scope": "recent_completed",
32
- "code.namespace": "DashboardController",
33
- "code.function": "index",
34
- "code.filepath": "app/controllers/dashboard_controller.rb",
35
- "code.lineno": 14,
36
- "db.query_count": 47
31
+ "code.activerecord.scope": "recent_completed",
32
+ "code.namespace": "BillingService",
33
+ "code.function": "monthly_summary",
34
+ "code.filepath": "app/services/billing_service.rb",
35
+ "code.lineno": 42,
36
+ "rails.controller": "ReportsController",
37
+ "rails.action": "index",
38
+ "db.query_count": 3
37
39
  }
38
40
  ```
39
41
 
40
- You navigate straight to the offending line. No grepping, no guessing.
42
+ Notice `code.namespace` is `BillingService`, not `ReportsController` — the gem walks the call stack and finds the service object that actually issued the query, not the controller that dispatched the request. No configuration required.
41
43
 
42
44
  ## Installation
43
45
 
44
46
  ```ruby
45
- gem 'rails-otel-context', '~> 0.7'
47
+ gem 'rails-otel-context', '~> 0.9'
46
48
  ```
47
49
 
48
- That's it. Everything installs automatically when Rails boots.
50
+ Add the gem, boot Rails. Everything else happens automatically.
51
+
52
+ ## What gets added to your spans
53
+
54
+ Every span — DB, Redis, HTTP outbound, custom — gets:
55
+
56
+ | Attribute | Example | Where it comes from |
57
+ |---|---|---|
58
+ | `code.namespace` | `"BillingService"` | Nearest app-code class in the call stack |
59
+ | `code.function` | `"monthly_summary"` | Method within that class |
60
+ | `code.filepath` | `"app/services/billing_service.rb"` | App-relative path |
61
+ | `code.lineno` | `42` | Source line number |
62
+ | `rails.controller` | `"ReportsController"` | Current Rails controller (set for every request) |
63
+ | `rails.action` | `"index"` | Current Rails action |
64
+ | `rails.job` | `"MonthlyInvoiceJob"` | ActiveJob class (set for every job, mutually exclusive with `rails.controller`) |
65
+
66
+ DB spans additionally get:
67
+
68
+ | Attribute | Example | Description |
69
+ |---|---|---|
70
+ | `code.activerecord.model` | `"Transaction"` | ActiveRecord model |
71
+ | `code.activerecord.method` | `"Load"` | AR operation (Load, Count, Update…) |
72
+ | `code.activerecord.scope` | `"recent_completed"` | Named scope or class method |
73
+ | `db.query_count` | `3` | Occurrence count this request — 2nd+ flags N+1 patterns |
74
+ | `db.slow` | `true` | Set when duration ≥ `slow_query_threshold_ms` |
75
+ | `db.async` | `true` | Set when issued via `load_async` (Rails 7.1+) |
49
76
 
50
77
  ## Configuration
51
78
 
52
- The defaults are sensible. A production initializer that gets the most out of this gem:
79
+ Zero configuration gets you everything above. The optional initializer adds span naming and slow-query detection:
53
80
 
54
81
  ```ruby
55
82
  # config/initializers/rails_otel_context.rb
56
83
  RailsOtelContext.configure do |c|
57
- # Rename DB spans to Model.scope makes traces scannable at a glance
58
- c.span_name_formatter = ->(original, ar) {
84
+ # Rename DB spans: prefer scope name, then calling method, then AR operation
85
+ #
86
+ # Priority (highest to lowest):
87
+ # 1. scope_name — named scope or class method returning a Relation
88
+ # e.g. User.active, Transaction.recent_completed
89
+ # 2. code_function when code_namespace == model — the model's own class method
90
+ # e.g. Transaction.total_revenue, User.for_account
91
+ # 3. method_name — AR operation: Load, Count, Update, Destroy…
92
+ # e.g. Transaction.Load, Transaction.Update
93
+ c.span_name_formatter = lambda { |original, ar|
59
94
  model = ar[:model_name]
60
95
  return original unless model
61
96
 
62
- scope = ar[:scope_name] ||
63
- (ar[:code_function] if ar[:code_namespace] == model && !ar[:code_function]&.start_with?('<')) ||
64
- ar[:method_name]
65
- "#{model}.#{scope}"
66
- }
97
+ scope = ar[:scope_name]
98
+ code_fn = ar[:code_function]
99
+ code_ns = ar[:code_namespace]
100
+ ar_op = ar[:method_name]
67
101
 
68
- # Carry controller + action name into every DB span fired during the request
69
- c.request_context_enabled = true
102
+ method = if scope
103
+ scope
104
+ elsif code_fn && code_ns == model && !code_fn.start_with?('<')
105
+ code_fn
106
+ else
107
+ ar_op
108
+ end
70
109
 
71
- # Flag slow queries (sets db.slow: true on spans exceeding the threshold)
110
+ "#{model}.#{method}"
111
+ }
112
+
113
+ # Flag slow queries
72
114
  c.slow_query_threshold_ms = 500
73
115
 
74
- # Attach any per-request context to every span in the trace
75
- c.custom_span_attributes = -> { { 'team' => Current.team } if Current.team }
116
+ # Attach any per-request context to every span
117
+ c.custom_span_attributes = -> { { 'tenant' => Current.tenant } if Current.tenant }
76
118
  end
77
119
  ```
78
120
 
79
- ## What gets added to your spans
121
+ ## How `code.namespace` / `code.function` works
80
122
 
81
- | Attribute | Example | Description |
123
+ On every span start, the gem walks the Ruby call stack (`Thread.each_caller_location`) and finds the first frame inside `Rails.root`. That frame becomes the four `code.*` attributes.
124
+
125
+ This means the right class shows up automatically at every layer:
126
+
127
+ | Caller | `code.namespace` | `code.function` |
82
128
  |---|---|---|
83
- | `code.activerecord.model` | `"Transaction"` | ActiveRecord model name |
84
- | `code.activerecord.method` | `"Load"` | AR operation (Load, Count, Update…) |
85
- | `code.activerecord.scope` | `"recent_completed"` | Named scope or class method that produced the query |
86
- | `code.namespace` | `"DashboardController"` | Ruby class that triggered the span |
87
- | `code.function` | `"index"` | Method name within that class |
88
- | `code.filepath` | `"app/controllers/..."` | App-relative source file |
89
- | `code.lineno` | `14` | Line number |
90
- | `request.controller` | `"DashboardController"` | Rails controller (requires `request_context_enabled`) |
91
- | `request.action` | `"index"` | Rails action (requires `request_context_enabled`) |
92
- | `db.query_count` | `47` | How many times this model+operation was queried in this request appears only on the 2nd+ occurrence, which flags N+1 patterns |
93
- | `db.slow` | `true` | Set when query duration exceeds `slow_query_threshold_ms` |
94
- | `db.async` | `true` | Set when query was issued via `load_async` (Rails 7.1+) |
129
+ | `ReportsController#index` calls `BillingService#monthly_summary` which queries | `BillingService` | `monthly_summary` |
130
+ | `UserRepository#find_active` queries directly | `UserRepository` | `find_active` |
131
+ | `OrdersController#create` queries directly | `OrdersController` | `create` |
132
+ | `MonthlyInvoiceJob#perform` queries | `MonthlyInvoiceJob` | `perform` |
133
+
134
+ No `include` statements. No `with_frame` calls. The nearest frame wins.
135
+
136
+ ### Override for hot paths
137
+
138
+ The stack walk is O(stack depth) roughly 15–25 frame iterations before reaching app code. For code paths that create thousands of spans per second, `FrameContext.with_frame` replaces the walk with a single thread-local read:
139
+
140
+ ```ruby
141
+ class ReportingPipeline
142
+ include RailsOtelContext::Frameable
143
+
144
+ def run
145
+ # All spans inside this block skip the stack walk.
146
+ # code.namespace: "ReportingPipeline", code.function: "run"
147
+ with_otel_frame { process_all_accounts }
148
+ end
149
+ end
150
+ ```
151
+
152
+ The pushed frame takes priority for the duration of the block. Outside the block, automatic stack-walk resumes.
95
153
 
96
154
  ## Span naming
97
155
 
98
- Without a formatter, DB spans arrive named by the driver (`SELECT`, `INSERT`). The formatter in the example above renames them using what this gem knows about each query:
156
+ Without a formatter, DB spans carry the driver's name (`SELECT`, `INSERT`). With the example formatter above:
99
157
 
100
- | What fired the query | Available context | Span name |
101
- |---|---|---|
102
- | `Transaction.recent_completed.to_a` | `scope_name: "recent_completed"` | `Transaction.recent_completed` |
103
- | `Transaction.total_revenue` | `code_function: "total_revenue"` | `Transaction.total_revenue` |
104
- | `Transaction.where(...).first` | `method_name: "Load"` | `Transaction.Load` |
105
- | `record.update(...)` | `method_name: "Update"` | `Transaction.Update` |
106
- | Counter cache, `connection.execute` | SQL parsed → table mapped to model | `User.Update` |
158
+ | Query | Result |
159
+ |---|---|
160
+ | `Transaction.recent_completed.to_a` | `Transaction.recent_completed` |
161
+ | `Transaction.total_revenue` (class method) | `Transaction.total_revenue` |
162
+ | `Transaction.where(...).first` | `Transaction.Load` |
163
+ | `record.update(...)` | `Transaction.Update` |
164
+ | Counter cache / `connection.execute` | `User.Update` (SQL parsed → table model) |
107
165
 
108
- The original span name is preserved in `l9.orig.name` so you can always filter on the raw driver operation.
166
+ The original name is preserved in `l9.orig.name`.
109
167
 
110
168
  ### Counter caches and raw SQL
111
169
 
112
- Rails counter caches, `touch_later`, and `connection.execute` calls fire `sql.active_record` with `payload[:name] = "SQL"` rather than `"User Update"`. The gem parses the SQL statement directly and maps the table name back to the AR model:
170
+ Rails counter caches, `touch_later`, and `connection.execute` fire `sql.active_record` with `payload[:name] = "SQL"`. The gem parses the statement and maps the table back to an AR model:
113
171
 
114
172
  ```
115
- UPDATE `users` SET `users`.`comments_count` = COALESCE(...)
116
- → code.activerecord.model: "User"
117
- code.activerecord.method: "Update"
118
- → span renamed to "User.Update" (with formatter)
119
- ```
120
-
121
- The table→model map is built from `AR::Base.descendants` on first use. In production with `eager_load!` this is always correct. In development, call this after a code reload if you see stale model names:
122
-
123
- ```ruby
124
- RailsOtelContext::ActiveRecordContext.reset_ar_table_model_map!
173
+ UPDATE `users` SET `users`.`comments_count` = ...
174
+ → code.activerecord.model: "User", method: "Update"
175
+ span renamed to "User.Update"
125
176
  ```
126
177
 
127
178
  ### Scope tracking
128
179
 
129
- The gem captures scope names from both the `scope` macro and plain class methods that return a relation:
180
+ Both `scope` macro methods and plain class methods returning a Relation are captured:
130
181
 
131
182
  ```ruby
132
183
  class Transaction < ApplicationRecord
133
- scope :recent_completed, -> { where(...) } # captured as code.activerecord.scope
184
+ scope :recent_completed, -> { where(...) } # code.activerecord.scope: "recent_completed"
134
185
 
135
186
  def self.for_account(id) # also captured
136
187
  where(account_id: id)
@@ -138,59 +189,34 @@ class Transaction < ApplicationRecord
138
189
  end
139
190
  ```
140
191
 
141
- ## Redis
142
-
143
- Redis source location tracking is off by default — it fires on every cache read and most Redis calls are fast. Turn it on when debugging:
144
-
145
- ```ruby
146
- c.redis_source_enabled = true
147
- ```
148
-
149
- ## ClickHouse
150
-
151
- No official OTel instrumentation exists for ClickHouse. This gem creates client spans automatically for `ClickHouse::Client`, `ClickHouse::Connection`, and `Clickhouse::Client`.
152
-
153
- ## Benchmarks
154
-
155
- The subscriber runs in the hot path of every SQL query. Two scripts live in `bench/`:
156
-
157
- ### Allocation guard (also runs in CI)
158
-
159
- Verifies the per-call allocation budget hasn't regressed. Deterministic — same count every run regardless of machine:
192
+ ## Redis and ClickHouse
160
193
 
161
- ```
162
- ruby bench/allocation_guard.rb
163
- ```
164
-
165
- ```
166
- PASS named query (User Load): 4.0 allocs/call (budget: 4)
167
- PASS SQL-named counter cache UPDATE: 6.0 allocs/call (budget: 6)
194
+ Redis and ClickHouse spans get the same `code.*` attributes pointing to the app-code frame that issued the call:
168
195
 
169
- All allocation budgets met.
196
+ ```json
197
+ {
198
+ "name": "SET",
199
+ "db.system": "redis",
200
+ "code.namespace": "SessionStore",
201
+ "code.function": "write",
202
+ "code.filepath": "app/lib/session_store.rb",
203
+ "code.lineno": 18,
204
+ "rails.controller": "SessionsController",
205
+ "rails.action": "create"
206
+ }
170
207
  ```
171
208
 
172
- Exits 1 if any path exceeds its budget. Update the budget constant in `bench/allocation_guard.rb` whenever a deliberate trade-off is made.
209
+ ## Performance
173
210
 
174
- ### Full profiling suite
211
+ `CallContextProcessor#on_start` fires for every span. For a typical 10–20 span request the overhead is in the low-microsecond range and does not require configuration.
175
212
 
176
- Throughput, per-call allocations with top allocating lines, and a StackProf CPU flamegraph:
213
+ For code paths that generate hundreds of spans per second, `FrameContext.with_frame` / `Frameable#with_otel_frame` replace the per-span stack walk with a single thread-local read. See [Override for hot paths](#override-for-hot-paths).
177
214
 
178
- ```
179
- bundle exec ruby bench/subscriber_hot_path.rb
180
- ```
215
+ ### Boot cost
181
216
 
182
- The flamegraph dump lands at `tmp/subscriber.dump`. View it with:
217
+ `ScopeNameTracking` hooks `singleton_method_added` on every AR model to detect class methods returning a Relation. On a large app (100+ models), this fires thousands of times during warm-up — one `source_location` call and one method redefinition per class method in `app/`. This is a one-time startup cost, not a per-request cost.
183
218
 
184
- ```
185
- stackprof --flamegraph tmp/subscriber.dump | open -f -a 'Google Chrome'
186
- ```
187
-
188
- **Baseline (Ruby 3.3, Apple M-series):**
189
-
190
- | Path | Allocs/call | Throughput |
191
- |---|---|---|
192
- | Named AR query (`User Load`) | 4 objects | ~900k i/s |
193
- | SQL counter cache (`name=SQL`) | 6 objects | ~650k i/s |
219
+ `ar_table_model_map` is built once at boot from `AR::Base.descendants`. In development, call `RailsOtelContext::ActiveRecordContext.reset_ar_table_model_map!` after a code reload if model names look stale.
194
220
 
195
221
  ## Requirements
196
222
 
@@ -1,32 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsOtelContext
4
- # SpanProcessor that enriches all spans with the calling Ruby class and method name,
5
- # and optionally with user-defined custom attributes.
4
+ # SpanProcessor that enriches ALL spans with:
5
+ # - code.namespace / code.function / code.filepath / code.lineno
6
+ # (nearest app-code frame from the call stack — automatic, no manual setup)
7
+ # - rails.controller / rails.action (when inside a controller action)
8
+ # - rails.job (when inside a job)
6
9
  #
7
- # Sets span attributes (unless the call stack yields no app-code frame):
8
- # - code.namespace – the class name, e.g. "OrderService", "InvoiceJob"
9
- # - code.function – the method name, e.g. "create", "perform"
10
- # - code.filepath – app-relative source file
11
- # - code.lineno – source line number
10
+ # Call-context resolution:
11
+ # 1. Explicit override — O(1). If app code calls FrameContext.with_frame (or
12
+ # includes Frameable), that frame wins. Use this to intentionally override
13
+ # the automatic nearest-frame (e.g., to expose a service boundary rather
14
+ # than the inner repo it delegates to).
15
+ # 2. Stack walk — O(stack depth). Default path when no override is active.
16
+ # DB adapters (Trilogy, PG, MySQL2, Redis, ClickHouse) additionally overwrite
17
+ # code.* post-query from a shallower position, giving exact call-site precision.
12
18
  #
13
- # Three-tier call-context resolution (fastest to slowest):
14
- # 1. Pushed frame — O(1) thread-local read. Set by Railtie around_action for
15
- # controllers, or manually via RailsOtelContext.with_frame.
16
- # 2. Stack walk — O(stack depth). Falls back here when no frame is pushed.
17
- # DB adapters (Trilogy, PG, MySQL2) additionally overwrite
18
- # code.* attributes post-query from a shallower stack position,
19
- # giving the exact call site (e.g. UserRepository#find_active:23).
19
+ # rails.* attributes come from RequestContext (thread-local set by Railtie hooks)
20
+ # and are applied unconditionally no config gate.
20
21
  #
21
- # Custom attributes (configured via +custom_span_attributes+) are applied to every span.
22
- # The callable must return a Hash (or nil) and must be fast — it runs in the hot path
23
- # of every span creation. Exceptions in the callable are silently rescued to avoid
24
- # disrupting application request processing.
22
+ # Custom attributes (configured via +custom_span_attributes+) are also applied.
23
+ # The callable must return a Hash (or nil) and must be fast — hot path per span.
25
24
  class CallContextProcessor
26
25
  include RailsOtelContext::SourceLocation
27
26
 
28
- SPAN_CONTROLLER_ATTR = 'request.controller'
29
- SPAN_ACTION_ATTR = 'request.action'
27
+ SPAN_CONTROLLER_ATTR = 'rails.controller'
28
+ SPAN_ACTION_ATTR = 'rails.action'
29
+ SPAN_JOB_ATTR = 'rails.job'
30
30
  AR_MODEL_ATTR = 'code.activerecord.model'
31
31
  AR_METHOD_ATTR = 'code.activerecord.method'
32
32
  AR_SCOPE_ATTR = 'code.activerecord.scope'
@@ -38,16 +38,15 @@ module RailsOtelContext
38
38
  attr_reader :app_root
39
39
 
40
40
  def initialize(app_root:, config: RailsOtelContext.configuration)
41
- @app_root = app_root.to_s
42
- @request_context_enabled = config.request_context_enabled
43
- @custom_span_attributes = config.custom_span_attributes
44
- @span_name_formatter = config.span_name_formatter
45
- @slow_query_threshold_ms = config.slow_query_threshold_ms
41
+ @app_root = app_root.to_s
42
+ @custom_span_attributes = config.custom_span_attributes
43
+ @span_name_formatter = config.span_name_formatter
44
+ @slow_query_threshold_ms = config.slow_query_threshold_ms
46
45
  end
47
46
 
48
47
  def on_start(span, _parent_context)
49
48
  apply_call_context(span)
50
- apply_request_context(span) if @request_context_enabled
49
+ apply_request_context(span)
51
50
  apply_db_context(span)
52
51
  apply_custom_attributes(span) if @custom_span_attributes
53
52
  end
@@ -79,34 +78,39 @@ module RailsOtelContext
79
78
  private
80
79
 
81
80
  def apply_call_context(span)
82
- # Fast path: caller pushed a frame explicitly — O(1), zero allocations.
81
+ # Explicit override: app code called FrameContext.with_frame (or Frameable).
82
+ # O(1) — no stack walk. Takes priority over automatic detection.
83
83
  pushed = FrameContext.current
84
84
  if pushed
85
85
  span.set_attribute('code.namespace', pushed[:class_name])
86
- span.set_attribute('code.function', pushed[:method_name]) if pushed[:method_name]
86
+ span.set_attribute('code.function', pushed[:method_name]) if pushed[:method_name]
87
87
  return
88
88
  end
89
89
 
90
- # Fallback: walk the call stack to find the first app-code frame.
90
+ # Default: walk the call stack to find the nearest app-code frame.
91
91
  return unless Thread.respond_to?(:each_caller_location)
92
92
 
93
93
  site = call_site_for_app
94
94
  return unless site
95
95
 
96
96
  span.set_attribute('code.namespace', site[:class_name])
97
- span.set_attribute('code.function', site[:method_name]) if site[:method_name]
97
+ span.set_attribute('code.function', site[:method_name]) if site[:method_name]
98
98
  return unless site[:lineno]
99
99
 
100
100
  span.set_attribute('code.filepath', site[:filepath])
101
- span.set_attribute('code.lineno', site[:lineno])
101
+ span.set_attribute('code.lineno', site[:lineno])
102
102
  end
103
103
 
104
104
  def apply_request_context(span)
105
105
  controller, action = RequestContext.fetch
106
- return unless controller
106
+ if controller
107
+ span.set_attribute(SPAN_CONTROLLER_ATTR, controller)
108
+ span.set_attribute(SPAN_ACTION_ATTR, action) if action
109
+ return
110
+ end
107
111
 
108
- span.set_attribute(SPAN_CONTROLLER_ATTR, controller)
109
- span.set_attribute(SPAN_ACTION_ATTR, action) if action
112
+ job = RequestContext.job
113
+ span.set_attribute(SPAN_JOB_ATTR, job) if job
110
114
  end
111
115
 
112
116
  def apply_db_context(span)
@@ -5,9 +5,12 @@ module RailsOtelContext
5
5
  attr_accessor :redis_source_enabled,
6
6
  :clickhouse_enabled,
7
7
  :span_name_formatter,
8
- :request_context_enabled,
9
8
  :slow_query_threshold_ms
10
9
 
10
+ # Deprecated: rails.controller / rails.action / rails.job are now always set
11
+ # on every span. This option is kept for backwards compatibility and has no effect.
12
+ attr_accessor :request_context_enabled
13
+
11
14
  attr_reader :custom_span_attributes
12
15
 
13
16
  def initialize
@@ -35,41 +35,33 @@ module RailsOtelContext
35
35
  RailsOtelContext::ActiveRecordContext.reset_ar_table_model_map!
36
36
  end
37
37
 
38
- # Push the controller class + action name as the active frame for every
39
- # controller action. Replaces the O(stack-depth) walk with a O(1) thread-local
40
- # read for every span created during the action. Always-on no config gate.
41
- initializer 'rails_otel_context.install_frame_context' do
38
+ # Capture controller + action for every request and propagate them to all
39
+ # child spans via RequestContext. Also resets the N+1 query counter at both
40
+ # the start and end of every request to prevent bleed across Puma thread reuse.
41
+ # Always-on — no config gate.
42
+ initializer 'rails_otel_context.install_request_context' do
42
43
  ActiveSupport.on_load(:action_controller) do
43
44
  around_action do |_controller, block|
44
- # Reset N+1 query counter at the start of every request so counts
45
- # never bleed across requests on Puma's reused threads. This runs
46
- # regardless of request_context_enabled, which only gates whether
47
- # RequestContext.set is called (and would reset it there too).
48
- Thread.current[RailsOtelContext::RequestContext::QUERY_COUNT_KEY] = nil
49
- RailsOtelContext::FrameContext.with_frame(
50
- class_name: self.class.name,
51
- method_name: action_name
52
- ) { block.call }
45
+ RailsOtelContext::RequestContext.set(
46
+ controller: self.class.name,
47
+ action: action_name
48
+ )
49
+ block.call
53
50
  ensure
54
- Thread.current[RailsOtelContext::RequestContext::QUERY_COUNT_KEY] = nil
51
+ RailsOtelContext::RequestContext.clear!
55
52
  end
56
53
  end
57
54
  end
58
55
 
59
- # Install request context capture on ActionController when it loads.
60
- # Uses around_action with ensure for leak-proof cleanup on exceptions.
61
- initializer 'rails_otel_context.install_request_context' do
62
- ActiveSupport.on_load(:action_controller) do
63
- if RailsOtelContext.configuration.request_context_enabled
64
- around_action do |_controller, block|
65
- RailsOtelContext::RequestContext.set(
66
- controller: self.class.name,
67
- action: action_name
68
- )
69
- block.call
70
- ensure
71
- RailsOtelContext::RequestContext.clear!
72
- end
56
+ # Capture job class name for every ActiveJob execution and propagate it to all
57
+ # child spans via RequestContext so rails.job appears on every span in the job.
58
+ initializer 'rails_otel_context.install_job_context' do
59
+ ActiveSupport.on_load(:active_job) do
60
+ around_perform do |_job, block|
61
+ RailsOtelContext::RequestContext.set_job(job_class: self.class.name)
62
+ block.call
63
+ ensure
64
+ RailsOtelContext::RequestContext.clear_job!
73
65
  end
74
66
  end
75
67
  end
@@ -1,19 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsOtelContext
4
- # Thread-local storage for request-scoped context that gets propagated
5
- # to all spans within a request. Uses raw Thread.current for minimal overhead —
6
- # no object allocation, no mutex, ~5ns per read/write.
4
+ # Thread-local storage for request/job-scoped context propagated to all spans.
5
+ # Uses raw Thread.current no object allocation, no mutex, ~5ns per read/write.
7
6
  #
8
- # Lifecycle:
7
+ # Controller lifecycle:
9
8
  # 1. Railtie's around_action sets controller + action at request start
10
9
  # 2. CallContextProcessor reads them on every child span's on_start
11
10
  # 3. around_action's ensure block clears them when the request ends
12
11
  #
13
- # Thread safety: each Puma thread has its own slot — no sharing, no contention.
12
+ # Job lifecycle:
13
+ # 1. Railtie's around_perform sets job at job start
14
+ # 2. CallContextProcessor reads it on every child span's on_start
15
+ # 3. around_perform's ensure block clears it when the job ends
16
+ #
17
+ # Thread safety: each Puma/Sidekiq thread owns its slot — no sharing, no contention.
14
18
  module RequestContext
15
19
  CONTROLLER_KEY = :_rails_otel_ctx_controller
16
20
  ACTION_KEY = :_rails_otel_ctx_action
21
+ JOB_KEY = :_rails_otel_ctx_job
17
22
  QUERY_COUNT_KEY = :_rails_otel_ctx_qcount
18
23
 
19
24
  class << self
@@ -23,6 +28,11 @@ module RailsOtelContext
23
28
  Thread.current[QUERY_COUNT_KEY] = nil
24
29
  end
25
30
 
31
+ def set_job(job_class:)
32
+ Thread.current[JOB_KEY] = job_class
33
+ Thread.current[QUERY_COUNT_KEY] = nil
34
+ end
35
+
26
36
  # Returns [controller, action] in a single Thread.current access.
27
37
  def fetch
28
38
  t = Thread.current
@@ -37,9 +47,19 @@ module RailsOtelContext
37
47
  Thread.current[ACTION_KEY]
38
48
  end
39
49
 
50
+ def job
51
+ Thread.current[JOB_KEY]
52
+ end
53
+
40
54
  def clear!
41
55
  Thread.current[CONTROLLER_KEY] = nil
42
56
  Thread.current[ACTION_KEY] = nil
57
+ Thread.current[JOB_KEY] = nil
58
+ Thread.current[QUERY_COUNT_KEY] = nil
59
+ end
60
+
61
+ def clear_job!
62
+ Thread.current[JOB_KEY] = nil
43
63
  Thread.current[QUERY_COUNT_KEY] = nil
44
64
  end
45
65
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsOtelContext
4
- VERSION = '0.8.5'
4
+ VERSION = '0.9.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-otel-context
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.5
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Last9