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 +4 -4
- data/README.md +61 -0
- data/lib/rails_otel_context/activerecord_context.rb +140 -22
- data/lib/rails_otel_context/adapters/mysql2.rb +1 -4
- data/lib/rails_otel_context/adapters/pg.rb +4 -2
- data/lib/rails_otel_context/adapters/trilogy.rb +1 -4
- data/lib/rails_otel_context/call_context_processor.rb +35 -40
- data/lib/rails_otel_context/frame_context.rb +73 -0
- data/lib/rails_otel_context/railtie.rb +14 -0
- data/lib/rails_otel_context/source_location.rb +76 -4
- data/lib/rails_otel_context/version.rb +1 -1
- data/lib/rails_otel_context.rb +14 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f9939fbc8981d0be3075a1c3f7dcbda1c87a0b0d40a5dc9186ace04aa05ef3aa
|
|
4
|
+
data.tar.gz: 88a58856ae020819ec9c17e5027d1b24b3c5da1d4609d3c43b909fa1e88df979
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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, :
|
|
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')
|
|
91
|
+
return if ar_name == 'SCHEMA' || ar_name.start_with?('CACHE')
|
|
72
92
|
|
|
73
|
-
ctx =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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]
|
|
196
|
+
Thread.current[THREAD_KEY] = nil
|
|
172
197
|
Thread.current[SCOPE_THREAD_KEY] = nil
|
|
173
|
-
Thread.current[
|
|
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
|
-
|
|
223
|
-
return nil unless
|
|
252
|
+
idx = name.index(' ')
|
|
253
|
+
return nil unless idx
|
|
224
254
|
|
|
225
|
-
model_name
|
|
226
|
-
method_name =
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
-
|
|
55
|
-
return unless
|
|
76
|
+
site = call_site_for_app
|
|
77
|
+
return unless site
|
|
56
78
|
|
|
57
|
-
span.set_attribute('code.namespace',
|
|
58
|
-
span.set_attribute('code.function',
|
|
59
|
-
return unless
|
|
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',
|
|
62
|
-
span.set_attribute('code.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
|
|
5
|
-
#
|
|
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(
|
|
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',
|
|
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
|
data/lib/rails_otel_context.rb
CHANGED
|
@@ -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.
|
|
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
|