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 +4 -4
- data/README.md +130 -104
- data/lib/rails_otel_context/call_context_processor.rb +38 -34
- data/lib/rails_otel_context/configuration.rb +4 -1
- data/lib/rails_otel_context/railtie.rb +20 -28
- data/lib/rails_otel_context/request_context.rb +25 -5
- data/lib/rails_otel_context/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 592224b44e9a4b7b72a85624dbfea6b05717b629ff9e707a4975cfc6db276b18
|
|
4
|
+
data.tar.gz: '0757947daefb592c0ef3e63c4d2396fb049461224c244aa145d51218c9f12f28'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1a695932a41348bf86b042b9535b95c9423161ac33873af45539386d3602225de51d430d9d01ac979736742a6cc79377a335d1f2dc07f93281cbdbaf71cfb89d
|
|
7
|
+
data.tar.gz: defbc699fa36e0ad33b907965ee6bb4cc47b7190d82acebede64f395bc25534bc6710b59de621f3693e7d33e82be705aa96ad1db95c101dfefdf3efd779089c1
|
data/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://github.com/last9/rails-otel-context/actions/workflows/ci.yml)
|
|
4
4
|
[](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
|
|
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":
|
|
29
|
+
"code.activerecord.model": "Transaction",
|
|
30
30
|
"code.activerecord.method": "Load",
|
|
31
|
-
"code.activerecord.scope":
|
|
32
|
-
"code.namespace":
|
|
33
|
-
"code.function":
|
|
34
|
-
"code.filepath":
|
|
35
|
-
"code.lineno":
|
|
36
|
-
"
|
|
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
|
-
|
|
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.
|
|
47
|
+
gem 'rails-otel-context', '~> 0.9'
|
|
46
48
|
```
|
|
47
49
|
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
58
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
75
|
-
c.custom_span_attributes = -> { { '
|
|
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
|
-
##
|
|
121
|
+
## How `code.namespace` / `code.function` works
|
|
80
122
|
|
|
81
|
-
|
|
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
|
-
| `
|
|
84
|
-
| `
|
|
85
|
-
| `
|
|
86
|
-
| `
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
156
|
+
Without a formatter, DB spans carry the driver's name (`SELECT`, `INSERT`). With the example formatter above:
|
|
99
157
|
|
|
100
|
-
|
|
|
101
|
-
|
|
102
|
-
| `Transaction.recent_completed.to_a` | `
|
|
103
|
-
| `Transaction.total_revenue`
|
|
104
|
-
| `Transaction.where(...).first` | `
|
|
105
|
-
| `record.update(...)` | `
|
|
106
|
-
| Counter cache
|
|
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
|
|
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`
|
|
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` =
|
|
116
|
-
→ code.activerecord.model: "User"
|
|
117
|
-
→
|
|
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
|
-
|
|
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(...) } #
|
|
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
|
-
|
|
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
|
-
|
|
209
|
+
## Performance
|
|
173
210
|
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
#
|
|
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
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
-
#
|
|
14
|
-
#
|
|
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
|
|
22
|
-
# The callable must return a Hash (or nil) and must be fast —
|
|
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 = '
|
|
29
|
-
SPAN_ACTION_ATTR = '
|
|
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
|
|
42
|
-
@
|
|
43
|
-
@
|
|
44
|
-
@
|
|
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)
|
|
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
|
-
#
|
|
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',
|
|
86
|
+
span.set_attribute('code.function', pushed[:method_name]) if pushed[:method_name]
|
|
87
87
|
return
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
#
|
|
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',
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
span.set_attribute(
|
|
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
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
51
|
+
RailsOtelContext::RequestContext.clear!
|
|
55
52
|
end
|
|
56
53
|
end
|
|
57
54
|
end
|
|
58
55
|
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
initializer 'rails_otel_context.
|
|
62
|
-
ActiveSupport.on_load(:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
5
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|