rails-otel-context 0.7.0 → 0.8.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: 33a9fb2ab0db69a25d4430149aa144b5bb88fe17929f595930320aa6d2adb230
4
- data.tar.gz: 6a8d7d881ffcc2c497223e8be0705409c58490086a6349ca27f07445a8e37b88
3
+ metadata.gz: f9939fbc8981d0be3075a1c3f7dcbda1c87a0b0d40a5dc9186ace04aa05ef3aa
4
+ data.tar.gz: 88a58856ae020819ec9c17e5027d1b24b3c5da1d4609d3c43b909fa1e88df979
5
5
  SHA512:
6
- metadata.gz: ad5399d6b648e82b479d37be7cf822a9c3eaa02c90a95e78e027caa2bdd7074c45b24b00d5d155b092ba2c1852daff4b10a8fe3b1c574c555c351fadb9af5972
7
- data.tar.gz: b53c74ebe800be5c48d86daa4ac90a4c50d8b56fc7f1a36bc36882eb3f1a33bc8fbe86e841144c1e8bad83b3248203cd7f84aec9bcf903a11568d8609c1422e3
6
+ metadata.gz: dc0385bb7df75a0e678dcf03e1b77fa6826427395504bea021e11282f4f1d9f67aadbae64f59cef0d971fe3a6ac2f3208c81d60ea8ef953e316f9196a2ad5c20
7
+ data.tar.gz: 468262bd9bf9b9df37705e0aab8d5f39b3610b5754407c4f6f1220594b297eba8f01b3538ddb6fa71ff5e037b4551f7bce97c30e9dac565b500b7c956fb79d54
data/README.md CHANGED
@@ -91,6 +91,7 @@ end
91
91
  | `request.action` | `"index"` | Rails action (requires `request_context_enabled`) |
92
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
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+) |
94
95
 
95
96
  ## Span naming
96
97
 
@@ -102,9 +103,27 @@ Without a formatter, DB spans arrive named by the driver (`SELECT`, `INSERT`). T
102
103
  | `Transaction.total_revenue` | `code_function: "total_revenue"` | `Transaction.total_revenue` |
103
104
  | `Transaction.where(...).first` | `method_name: "Load"` | `Transaction.Load` |
104
105
  | `record.update(...)` | `method_name: "Update"` | `Transaction.Update` |
106
+ | Counter cache, `connection.execute` | SQL parsed → table mapped to model | `User.Update` |
105
107
 
106
108
  The original span name is preserved in `l9.orig.name` so you can always filter on the raw driver operation.
107
109
 
110
+ ### Counter caches and raw SQL
111
+
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:
113
+
114
+ ```
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!
125
+ ```
126
+
108
127
  ### Scope tracking
109
128
 
110
129
  The gem captures scope names from both the `scope` macro and plain class methods that return a relation:
@@ -131,6 +150,48 @@ c.redis_source_enabled = true
131
150
 
132
151
  No official OTel instrumentation exists for ClickHouse. This gem creates client spans automatically for `ClickHouse::Client`, `ClickHouse::Connection`, and `Clickhouse::Client`.
133
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:
160
+
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)
168
+
169
+ All allocation budgets met.
170
+ ```
171
+
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.
173
+
174
+ ### Full profiling suite
175
+
176
+ Throughput, per-call allocations with top allocating lines, and a StackProf CPU flamegraph:
177
+
178
+ ```
179
+ bundle exec ruby bench/subscriber_hot_path.rb
180
+ ```
181
+
182
+ The flamegraph dump lands at `tmp/subscriber.dump`. View it with:
183
+
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 |
194
+
134
195
  ## Requirements
135
196
 
136
197
  - Ruby >= 3.1
@@ -15,9 +15,29 @@ module RailsOtelContext
15
15
  module ActiveRecordContext
16
16
  THREAD_KEY = :_rails_otel_ctx_ar
17
17
  SCOPE_THREAD_KEY = :_rails_otel_ctx_scope
18
- TIMING_THREAD_KEY = :_rails_otel_ctx_timing
18
+ TIMING_ID_KEY = :_rails_otel_ctx_timing_id
19
+ TIMING_START_KEY = :_rails_otel_ctx_timing_start
19
20
  DB_SLOW_ATTR = 'db.slow'
20
- private_constant :THREAD_KEY, :SCOPE_THREAD_KEY, :TIMING_THREAD_KEY
21
+ private_constant :THREAD_KEY, :SCOPE_THREAD_KEY, :TIMING_ID_KEY, :TIMING_START_KEY
22
+
23
+ # Frozen regex — only the verb regex remains; table extraction uses index+slice.
24
+ SQL_VERB_RE = /\A(\w+)/i
25
+ private_constant :SQL_VERB_RE
26
+
27
+ # Keywords for table extraction — frozen literals searched with index+slice.
28
+ # Rails-generated SQL uses uppercase keywords, covering counter caches and touch_later.
29
+ KW_UPDATE = 'UPDATE '
30
+ KW_INTO = 'INTO '
31
+ KW_FROM = 'FROM '
32
+ private_constant :KW_UPDATE, :KW_INTO, :KW_FROM
33
+
34
+ # Byte values used in extract_table_after for delimiter detection.
35
+ BYTE_BACKTICK = 96 # `
36
+ BYTE_DQUOTE = 34 # "
37
+ BYTE_SQUOTE = 39 # '
38
+ BYTE_SPACE = 32 # (space)
39
+ BYTE_COMMA = 44 # ,
40
+ private_constant :BYTE_BACKTICK, :BYTE_DQUOTE, :BYTE_SQUOTE, :BYTE_SPACE, :BYTE_COMMA
21
41
 
22
42
  # Tracks class methods (def self.name) that return an AR::Relation so their
23
43
  # name is captured as code.activerecord.scope, complementing ScopeNameTracking
@@ -68,23 +88,31 @@ module RailsOtelContext
68
88
  def start(_name, id, payload)
69
89
  ar_name = payload[:name]
70
90
  return unless ar_name
71
- return if ar_name == 'SCHEMA' || ar_name.start_with?('CACHE') || ar_name == 'SQL'
91
+ return if ar_name == 'SCHEMA' || ar_name.start_with?('CACHE')
72
92
 
73
- ctx = ActiveRecordContext.parse_ar_name(ar_name)
93
+ ctx = if ar_name == 'SQL'
94
+ ActiveRecordContext.parse_sql_context(payload[:sql])
95
+ else
96
+ ActiveRecordContext.parse_ar_name(ar_name)
97
+ end
74
98
  return unless ctx
75
99
 
76
100
  # Include scope name if one was captured by RelationScopeCapture
77
101
  scope = Thread.current[SCOPE_THREAD_KEY]
78
102
  ctx[:scope_name] = scope if scope
79
103
 
80
- query_key = "#{ctx[:model_name]}.#{ctx[:method_name]}"
104
+ query_key = ctx[:query_key]
81
105
  counts = (Thread.current[RequestContext::QUERY_COUNT_KEY] ||= {})
82
106
  count = (counts[query_key] = (counts[query_key] || 0) + 1)
83
107
  ctx[:query_count] = count if count > 1
84
108
 
109
+ ctx[:async] = true if payload[:async]
85
110
  Thread.current[THREAD_KEY] = ctx
86
111
 
87
- Thread.current[TIMING_THREAD_KEY] = [id, Process.clock_gettime(Process::CLOCK_MONOTONIC)] if @threshold
112
+ if @threshold
113
+ Thread.current[TIMING_ID_KEY] = id
114
+ Thread.current[TIMING_START_KEY] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
115
+ end
88
116
 
89
117
  # Enrich the current span directly. When OTel instruments via driver-level
90
118
  # prepend (Trilogy, PG, Mysql2), the span is created BEFORE this notification
@@ -96,15 +124,12 @@ module RailsOtelContext
96
124
  end
97
125
 
98
126
  def finish(_name, id, _payload)
99
- if @threshold
100
- entry = Thread.current[TIMING_THREAD_KEY]
101
- if entry&.first.equal?(id)
102
- Thread.current[TIMING_THREAD_KEY] = nil
103
- elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - entry[1]) * 1000
104
- if elapsed_ms >= @threshold
105
- span = OpenTelemetry::Trace.current_span
106
- span.set_attribute(DB_SLOW_ATTR, true) if span.context.valid?
107
- end
127
+ if @threshold && Thread.current[TIMING_ID_KEY].equal?(id)
128
+ elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - Thread.current[TIMING_START_KEY]) * 1000
129
+ Thread.current[TIMING_ID_KEY] = nil
130
+ if elapsed_ms >= @threshold
131
+ span = OpenTelemetry::Trace.current_span
132
+ span.set_attribute(DB_SLOW_ATTR, true) if span.context.valid?
108
133
  end
109
134
  end
110
135
  ensure
@@ -168,9 +193,10 @@ module RailsOtelContext
168
193
  end
169
194
 
170
195
  def clear!
171
- Thread.current[THREAD_KEY] = nil
196
+ Thread.current[THREAD_KEY] = nil
172
197
  Thread.current[SCOPE_THREAD_KEY] = nil
173
- Thread.current[TIMING_THREAD_KEY] = nil
198
+ Thread.current[TIMING_ID_KEY] = nil
199
+ Thread.current[TIMING_START_KEY] = nil
174
200
  end
175
201
 
176
202
  # Test helpers: set AR context directly for unit tests.
@@ -193,6 +219,7 @@ module RailsOtelContext
193
219
  span.set_attribute('code.activerecord.method', ctx[:method_name]) if ctx[:method_name]
194
220
  span.set_attribute('code.activerecord.scope', ctx[:scope_name]) if ctx[:scope_name]
195
221
  span.set_attribute('db.query_count', ctx[:query_count]) if ctx[:query_count]
222
+ span.set_attribute('db.async', true) if ctx[:async]
196
223
 
197
224
  formatter = RailsOtelContext.configuration.span_name_formatter
198
225
  return unless formatter
@@ -216,18 +243,109 @@ module RailsOtelContext
216
243
  end
217
244
 
218
245
  # Parses "Transaction Load" → { model_name: "Transaction", method_name: "Load" }
246
+ # Uses index+byteslice instead of split to avoid allocating the intermediate Array
247
+ # and two String copies — saves 1 alloc (Array) vs split which returns Array+Strings
248
+ # but with frozen_string_literal the slice shares storage on MRI 3.2+.
219
249
  def parse_ar_name(name)
220
250
  return nil unless name
221
251
 
222
- parts = name.split(' ', 2)
223
- return nil unless parts.size == 2
252
+ idx = name.index(' ')
253
+ return nil unless idx
224
254
 
225
- model_name = parts[0]
226
- method_name = parts[1]
255
+ model_name = name[0, idx]
256
+ method_name = name[idx + 1, name.length]
227
257
 
228
258
  return nil if model_name == 'ActiveRecord'
229
259
 
230
- { model_name: model_name, method_name: method_name }
260
+ { model_name: model_name, method_name: method_name,
261
+ query_key: "#{model_name}.#{method_name}".freeze }
262
+ end
263
+
264
+ # Parses raw SQL (payload[:name] == "SQL") to extract model context.
265
+ # Used for counter cache updates, touch_later, and connection.execute calls
266
+ # that fire sql.active_record with name="SQL" rather than "Model Method".
267
+ #
268
+ # Returns nil when the table cannot be mapped to a known AR model, so callers
269
+ # fall through to the existing skip path.
270
+ def parse_sql_context(sql)
271
+ return nil unless sql
272
+
273
+ verb = sql[SQL_VERB_RE, 1]&.capitalize
274
+ return nil unless verb
275
+
276
+ keyword = case verb
277
+ when 'Update' then KW_UPDATE
278
+ when 'Insert' then KW_INTO
279
+ when 'Delete', 'Select' then KW_FROM
280
+ end
281
+ return nil unless keyword
282
+
283
+ table = extract_table_after(sql, keyword)
284
+ return nil unless table
285
+
286
+ model_name = ar_table_model_map[table]
287
+ return nil unless model_name
288
+
289
+ { model_name: model_name, method_name: verb,
290
+ query_key: "#{model_name}.#{verb}".freeze }
291
+ end
292
+
293
+ # Extracts a table name from +sql+ starting after +keyword+.
294
+ # Skips one optional leading quote character (` " '), reads word chars until
295
+ # the next delimiter (space, quote, comma). Returns nil when keyword not found.
296
+ # Saves 1 alloc vs regex: index()+slice allocates only the result String.
297
+ def extract_table_after(sql, keyword)
298
+ idx = sql.index(keyword)
299
+ return nil unless idx
300
+
301
+ start = idx + keyword.length
302
+ first = sql.getbyte(start)
303
+ start += 1 if first == BYTE_BACKTICK || first == BYTE_DQUOTE || first == BYTE_SQUOTE # rubocop:disable Style/MultipleComparison
304
+
305
+ stop = start
306
+ stop += 1 while stop < sql.length &&
307
+ (b = sql.getbyte(stop)) != BYTE_SPACE &&
308
+ b != BYTE_BACKTICK && b != BYTE_DQUOTE && b != BYTE_SQUOTE && b != BYTE_COMMA
309
+
310
+ stop > start ? sql[start, stop - start] : nil
311
+ end
312
+
313
+ # Lazy table_name → model_name index. Built on first use after all models are
314
+ # loaded (eager_load! in production). In development, call reset_ar_table_model_map!
315
+ # after a code reload to get a fresh index.
316
+ def ar_table_model_map
317
+ @ar_table_model_map ||= begin
318
+ next_map = {}
319
+ if defined?(::ActiveRecord::Base)
320
+ ::ActiveRecord::Base.descendants.each do |m|
321
+ model_name = begin
322
+ m.name
323
+ rescue StandardError
324
+ next
325
+ end
326
+ next unless model_name
327
+ # Skip STI subclasses — they share the parent's table_name.
328
+ # Without this guard, AdminUser < User would overwrite users → User
329
+ # with users → AdminUser in the map, giving wrong model names on
330
+ # SQL-named spans (counter caches, update_all).
331
+ next unless m.base_class == m
332
+
333
+ table = begin
334
+ m.table_name
335
+ rescue StandardError
336
+ next
337
+ end
338
+ next unless table
339
+
340
+ next_map[table] = model_name
341
+ end
342
+ end
343
+ next_map
344
+ end
345
+ end
346
+
347
+ def reset_ar_table_model_map!
348
+ @ar_table_model_map = nil
231
349
  end
232
350
  end
233
351
  end
@@ -36,10 +36,7 @@ module RailsOtelContext
36
36
  %i[query prepare].each do |method_name|
37
37
  define_method(method_name) do |*args|
38
38
  result = super(*args)
39
-
40
- span = OpenTelemetry::Trace.current_span
41
- mod.apply_source_to_span(span, mod.source_location_for_app) if span.context.valid?
42
-
39
+ mod.apply_call_site_to_span(OpenTelemetry::Trace.current_span, mod.call_site_for_app)
43
40
  result
44
41
  end
45
42
  end
@@ -45,10 +45,12 @@ module RailsOtelContext
45
45
  # AR context and span renaming handled by CallContextProcessor.apply_db_context.
46
46
  methods.each do |method_name|
47
47
  define_method(method_name) do |*args, &user_block|
48
- source = mod.source_location_for_app
48
+ # Capture before super: PG yields into a block so the original
49
+ # call stack is only visible here, not inside the result block.
50
+ site = mod.call_site_for_app
49
51
 
50
52
  super(*args) do |result|
51
- mod.apply_source_to_span(OpenTelemetry::Trace.current_span, source)
53
+ mod.apply_call_site_to_span(OpenTelemetry::Trace.current_span, site)
52
54
  user_block ? user_block.call(result) : result
53
55
  end
54
56
  end
@@ -35,10 +35,7 @@ module RailsOtelContext
35
35
  # AR context and span renaming handled by CallContextProcessor.apply_db_context.
36
36
  define_method(:query) do |sql|
37
37
  result = super(sql)
38
-
39
- span = OpenTelemetry::Trace.current_span
40
- mod.apply_source_to_span(span, mod.source_location_for_app) if span.context.valid?
41
-
38
+ mod.apply_call_site_to_span(OpenTelemetry::Trace.current_span, mod.call_site_for_app)
42
39
  result
43
40
  end
44
41
  end
@@ -4,28 +4,39 @@ module RailsOtelContext
4
4
  # SpanProcessor that enriches all spans with the calling Ruby class and method name,
5
5
  # and optionally with user-defined custom attributes.
6
6
  #
7
- # Sets two attributes on every span (unless the call stack yields no app-code frame):
7
+ # Sets span attributes (unless the call stack yields no app-code frame):
8
8
  # - code.namespace – the class name, e.g. "OrderService", "InvoiceJob"
9
9
  # - code.function – the method name, e.g. "create", "perform"
10
+ # - code.filepath – app-relative source file
11
+ # - code.lineno – source line number
10
12
  #
11
- # Class names are extracted from the frame label when available (e.g. "User.find"),
12
- # and inferred from the file-path basename otherwise (e.g. order_service.rb OrderService).
13
- #
14
- # Frames inside gems or outside app_root are always skipped.
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).
15
20
  #
16
21
  # Custom attributes (configured via +custom_span_attributes+) are applied to every span.
17
22
  # The callable must return a Hash (or nil) and must be fast — it runs in the hot path
18
23
  # of every span creation. Exceptions in the callable are silently rescued to avoid
19
24
  # disrupting application request processing.
20
25
  class CallContextProcessor
26
+ include RailsOtelContext::SourceLocation
27
+
21
28
  SPAN_CONTROLLER_ATTR = 'request.controller'
22
29
  SPAN_ACTION_ATTR = 'request.action'
23
30
  AR_MODEL_ATTR = 'code.activerecord.model'
24
31
  AR_METHOD_ATTR = 'code.activerecord.method'
25
32
  AR_SCOPE_ATTR = 'code.activerecord.scope'
26
33
  AR_QUERY_COUNT_ATTR = 'db.query_count'
34
+ AR_ASYNC_ATTR = 'db.async'
27
35
  ORIG_NAME_ATTR = 'l9.orig.name'
28
36
 
37
+ # Exposed so SourceLocation mixin can use it for the stack-walk path.
38
+ attr_reader :app_root
39
+
29
40
  def initialize(app_root:, config: RailsOtelContext.configuration)
30
41
  @app_root = app_root.to_s
31
42
  @request_context_enabled = config.request_context_enabled
@@ -49,17 +60,28 @@ module RailsOtelContext
49
60
  private
50
61
 
51
62
  def apply_call_context(span)
63
+ # Fast path: caller pushed a frame explicitly — O(1), zero allocations.
64
+ # DB adapters will overwrite this with the exact call site post-query.
65
+ pushed = FrameContext.current
66
+ if pushed
67
+ span.set_attribute('code.namespace', pushed[:class_name])
68
+ span.set_attribute('code.function', pushed[:method_name]) if pushed[:method_name]
69
+ return
70
+ end
71
+
72
+ # Fallback: walk the call stack. DB spans without a pushed frame take this
73
+ # path; the adapter's post-query walk (shallower) will overwrite the result.
52
74
  return unless Thread.respond_to?(:each_caller_location)
53
75
 
54
- context = extract_caller_context
55
- return unless context
76
+ site = call_site_for_app
77
+ return unless site
56
78
 
57
- span.set_attribute('code.namespace', context[:class_name])
58
- span.set_attribute('code.function', context[:method_name]) if context[:method_name]
59
- return unless context[:lineno]
79
+ span.set_attribute('code.namespace', site[:class_name])
80
+ span.set_attribute('code.function', site[:method_name]) if site[:method_name]
81
+ return unless site[:lineno]
60
82
 
61
- span.set_attribute('code.filepath', context[:filepath])
62
- span.set_attribute('code.lineno', context[:lineno])
83
+ span.set_attribute('code.filepath', site[:filepath])
84
+ span.set_attribute('code.lineno', site[:lineno])
63
85
  end
64
86
 
65
87
  def apply_request_context(span)
@@ -95,6 +117,7 @@ module RailsOtelContext
95
117
  span.set_attribute(AR_METHOD_ATTR, ar_context[:method_name]) if ar_context[:method_name]
96
118
  span.set_attribute(AR_SCOPE_ATTR, ar_context[:scope_name]) if ar_context[:scope_name]
97
119
  span.set_attribute(AR_QUERY_COUNT_ATTR, ar_context[:query_count]) if ar_context[:query_count]
120
+ span.set_attribute(AR_ASYNC_ATTR, true) if ar_context[:async]
98
121
  end
99
122
 
100
123
  def apply_span_name_formatter(span, ar_context)
@@ -118,33 +141,5 @@ module RailsOtelContext
118
141
  rescue StandardError
119
142
  # Never let a user-supplied callback break span processing.
120
143
  end
121
-
122
- def extract_caller_context
123
- Thread.each_caller_location do |location|
124
- path = location.absolute_path || location.path
125
- next unless path&.start_with?(@app_root)
126
- next if path.include?('/gems/')
127
-
128
- label = location.label || ''
129
- lineno = location.lineno
130
- filepath = path.delete_prefix("#{@app_root}/")
131
-
132
- # Try label first: "ClassName.method" or "ClassName#method"
133
- if label =~ /^([A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*)(\.|\#)/
134
- class_name = Regexp.last_match(1)
135
- method_name = label.split(/[.\#]/, 2).last
136
- &.sub(/^(?:block|rescue|ensure) in /, '')
137
- return { class_name: class_name, method_name: method_name, lineno: lineno, filepath: filepath }
138
- end
139
-
140
- # Fallback: infer class from file-path basename (snake_case → CamelCase)
141
- class_name = File.basename(path, '.rb').split('_').map(&:capitalize).join
142
- method_name = label.sub(/^(?:block|rescue|ensure) in /, '')
143
- return { class_name: class_name, method_name: method_name.empty? ? nil : method_name,
144
- lineno: lineno, filepath: filepath }
145
- end
146
-
147
- nil
148
- end
149
144
  end
150
145
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOtelContext
4
+ # Thread-local storage for explicitly pushed call-frame context.
5
+ #
6
+ # The default code.namespace / code.function attributes are extracted by
7
+ # walking the Ruby call stack on every span start. That works, but costs
8
+ # O(stack depth) object allocations per span. FrameContext eliminates the
9
+ # walk by letting call sites push their class+method once at entry:
10
+ #
11
+ # RailsOtelContext::FrameContext.with_frame(class_name: 'OrdersController',
12
+ # method_name: 'create') do
13
+ # # every span created inside here reads the pushed frame — no stack walk
14
+ # end
15
+ #
16
+ # The Railtie automatically installs an around_action that pushes the
17
+ # controller frame for all controller actions. For jobs, service objects, or
18
+ # any other code that creates spans, use +with_frame+ directly or include
19
+ # +RailsOtelContext::Frameable+.
20
+ #
21
+ # The pushed frame is a fallback for the span processor: the stack walk
22
+ # still runs when no frame is pushed, so existing behavior is preserved.
23
+ module FrameContext
24
+ FRAME_KEY = :_rails_otel_ctx_frame
25
+ private_constant :FRAME_KEY
26
+
27
+ class << self
28
+ # Pushes +class_name+/+method_name+ for the duration of the block,
29
+ # restoring whatever was pushed before (supports nesting).
30
+ def with_frame(class_name:, method_name:)
31
+ prev = Thread.current[FRAME_KEY]
32
+ Thread.current[FRAME_KEY] = { class_name: class_name, method_name: method_name }.freeze
33
+ yield
34
+ ensure
35
+ Thread.current[FRAME_KEY] = prev
36
+ end
37
+
38
+ # Manual push without a block. Caller must call +pop+ in an ensure.
39
+ def push(class_name:, method_name:)
40
+ Thread.current[FRAME_KEY] = { class_name: class_name, method_name: method_name }.freeze
41
+ end
42
+
43
+ # Clears the pushed frame. Pair with +push+ in an ensure block.
44
+ def pop
45
+ Thread.current[FRAME_KEY] = nil
46
+ end
47
+
48
+ # Returns the currently pushed frame hash, or nil.
49
+ def current
50
+ Thread.current[FRAME_KEY]
51
+ end
52
+
53
+ alias clear! pop
54
+ end
55
+ end
56
+
57
+ # Include in any class to get a +with_otel_frame+ convenience wrapper that
58
+ # pushes self.class.name + the calling method name automatically.
59
+ #
60
+ # class InvoiceService
61
+ # include RailsOtelContext::Frameable
62
+ #
63
+ # def call
64
+ # with_otel_frame { do_work }
65
+ # end
66
+ # end
67
+ module Frameable
68
+ def with_otel_frame(method_name = nil, &)
69
+ name = method_name || caller_locations(1, 1).first&.label || '<unknown>'
70
+ FrameContext.with_frame(class_name: self.class.name, method_name: name, &)
71
+ end
72
+ end
73
+ end
@@ -20,6 +20,20 @@ module RailsOtelContext
20
20
  end
21
21
  end
22
22
 
23
+ # Push the controller class + action name as the active frame for every
24
+ # controller action. Replaces the O(stack-depth) walk with a O(1) thread-local
25
+ # read for every span created during the action. Always-on — no config gate.
26
+ initializer 'rails_otel_context.install_frame_context' do
27
+ ActiveSupport.on_load(:action_controller) do
28
+ around_action do |_controller, block|
29
+ RailsOtelContext::FrameContext.with_frame(
30
+ class_name: self.class.name,
31
+ method_name: action_name
32
+ ) { block.call }
33
+ end
34
+ end
35
+ end
36
+
23
37
  # Install request context capture on ActionController when it loads.
24
38
  # Uses around_action with ensure for leak-proof cleanup on exceptions.
25
39
  initializer 'rails_otel_context.install_request_context' do
@@ -1,28 +1,100 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsOtelContext
4
- # Shared helper for finding the first app-code source location in the call stack.
5
- # Used by all DB adapters to attach source file/line to query spans.
4
+ # Shared call-site extraction for DB adapters and CallContextProcessor.
5
+ #
6
+ # Every adapter (Trilogy, PG, MySQL2) includes this on class << self and
7
+ # calls call_site_for_app after (or around) the query. Because the adapter
8
+ # is closer to user code in the call stack than CallContextProcessor#on_start
9
+ # is, the walk reaches the app frame in fewer iterations — and returns
10
+ # class + method + filepath + lineno in one shot.
11
+ #
12
+ # CallContextProcessor includes this too so it can share the same logic in
13
+ # its stack-walk fallback path.
6
14
  module SourceLocation
15
+ # Regex constants — compiled once, shared by all includers.
16
+ CLASS_LABEL_RE = /^([A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*)[.#]/
17
+ BLOCK_LABEL_RE = /^(?:block|rescue|ensure) in /
18
+ METHOD_SPLIT_RE = /[.#]/
19
+ private_constant :CLASS_LABEL_RE, :BLOCK_LABEL_RE, :METHOD_SPLIT_RE
20
+
21
+ # Returns the first app-code frame as a Hash:
22
+ # { class_name:, method_name:, filepath:, lineno: }
23
+ # Returns nil when no app frame is found or the feature is unavailable.
24
+ # Requires +app_root+ to be defined on the including object.
25
+ def call_site_for_app
26
+ return unless Thread.respond_to?(:each_caller_location)
27
+
28
+ Thread.each_caller_location do |location|
29
+ path = location.absolute_path || location.path
30
+ next unless path&.start_with?(app_root)
31
+ next if path.include?('/gems/')
32
+
33
+ return build_call_site(location, path)
34
+ end
35
+
36
+ nil
37
+ end
38
+
39
+ # Applies all four call-site attributes to +span+ in one go.
40
+ def apply_call_site_to_span(span, site)
41
+ return unless site && span.context.valid?
42
+
43
+ span.set_attribute('code.namespace', site[:class_name])
44
+ span.set_attribute('code.function', site[:method_name]) if site[:method_name]
45
+ span.set_attribute('code.filepath', site[:filepath])
46
+ span.set_attribute('code.lineno', site[:lineno]) if site[:lineno]
47
+ end
48
+
49
+ # Legacy helper kept for Redis and ClickHouse adapters that only need filepath + lineno.
50
+ # Migrate those adapters to call_site_for_app + apply_call_site_to_span to remove this.
7
51
  def source_location_for_app
8
52
  return unless Thread.respond_to?(:each_caller_location)
9
53
 
54
+ prefix = app_root_prefix
10
55
  Thread.each_caller_location do |location|
11
56
  path = location.absolute_path || location.path
12
57
  next unless path&.start_with?(app_root)
13
58
  next if path.include?('/gems/')
14
59
 
15
- return [path.delete_prefix("#{app_root}/"), location.lineno]
60
+ return [path.delete_prefix(prefix), location.lineno]
16
61
  end
17
62
 
18
63
  nil
19
64
  end
20
65
 
66
+ # Legacy helper — use apply_call_site_to_span for new code.
21
67
  def apply_source_to_span(span, source)
22
68
  return unless source
23
69
 
24
70
  span.set_attribute('code.filepath', source[0])
25
- span.set_attribute('code.lineno', source[1])
71
+ span.set_attribute('code.lineno', source[1])
72
+ end
73
+
74
+ private
75
+
76
+ def app_root_prefix
77
+ @app_root_prefix ||= "#{app_root}/"
78
+ end
79
+
80
+ def build_call_site(location, path)
81
+ label = location.label || ''
82
+ lineno = location.lineno
83
+ filepath = path.delete_prefix(app_root_prefix)
84
+
85
+ if label =~ CLASS_LABEL_RE
86
+ class_name = Regexp.last_match(1)
87
+ method_name = label.split(METHOD_SPLIT_RE, 2).last
88
+ &.sub(BLOCK_LABEL_RE, '')
89
+ return { class_name: class_name, method_name: method_name,
90
+ lineno: lineno, filepath: filepath }
91
+ end
92
+
93
+ class_name = File.basename(path, '.rb').split('_').map(&:capitalize).join
94
+ method_name = label.sub(BLOCK_LABEL_RE, '')
95
+ { class_name: class_name,
96
+ method_name: method_name.empty? ? nil : method_name,
97
+ lineno: lineno, filepath: filepath }
26
98
  end
27
99
  end
28
100
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsOtelContext
4
- VERSION = '0.7.0'
4
+ VERSION = '0.8.0'
5
5
  end
@@ -12,6 +12,7 @@ require 'rails_otel_context/source_location'
12
12
  require 'rails_otel_context/activerecord_context'
13
13
  require 'rails_otel_context/adapters'
14
14
  require 'rails_otel_context/request_context'
15
+ require 'rails_otel_context/frame_context'
15
16
  require 'rails_otel_context/call_context_processor'
16
17
  require 'rails_otel_context/railtie' if defined?(Rails::Railtie)
17
18
 
@@ -28,5 +29,18 @@ module RailsOtelContext
28
29
  def reset_configuration!
29
30
  @configuration = Configuration.new
30
31
  end
32
+
33
+ # Convenience delegates to FrameContext — see FrameContext for full docs.
34
+ def with_frame(class_name:, method_name:, &block)
35
+ FrameContext.with_frame(class_name: class_name, method_name: method_name, &block)
36
+ end
37
+
38
+ def push_frame(class_name:, method_name:)
39
+ FrameContext.push(class_name: class_name, method_name: method_name)
40
+ end
41
+
42
+ def pop_frame
43
+ FrameContext.pop
44
+ end
31
45
  end
32
46
  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.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Last9
@@ -71,6 +71,7 @@ files:
71
71
  - lib/rails_otel_context/adapters/trilogy.rb
72
72
  - lib/rails_otel_context/call_context_processor.rb
73
73
  - lib/rails_otel_context/configuration.rb
74
+ - lib/rails_otel_context/frame_context.rb
74
75
  - lib/rails_otel_context/railtie.rb
75
76
  - lib/rails_otel_context/request_context.rb
76
77
  - lib/rails_otel_context/source_location.rb