rails-otel-context 0.8.3 → 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/activerecord_context.rb +6 -29
- data/lib/rails_otel_context/adapters/clickhouse.rb +2 -2
- data/lib/rails_otel_context/adapters/redis.rb +14 -26
- data/lib/rails_otel_context/call_context_processor.rb +57 -34
- data/lib/rails_otel_context/configuration.rb +4 -1
- data/lib/rails_otel_context/railtie.rb +21 -22
- 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
|
|
|
@@ -13,12 +13,10 @@ module RailsOtelContext
|
|
|
13
13
|
# Transaction.recent_completed.to_a where the scope method returns before
|
|
14
14
|
# SQL fires.
|
|
15
15
|
module ActiveRecordContext
|
|
16
|
-
THREAD_KEY
|
|
17
|
-
SCOPE_THREAD_KEY
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
DB_SLOW_ATTR = 'db.slow'
|
|
21
|
-
private_constant :THREAD_KEY, :SCOPE_THREAD_KEY, :TIMING_ID_KEY, :TIMING_START_KEY
|
|
16
|
+
THREAD_KEY = :_rails_otel_ctx_ar
|
|
17
|
+
SCOPE_THREAD_KEY = :_rails_otel_ctx_scope
|
|
18
|
+
DB_SLOW_ATTR = 'db.slow'
|
|
19
|
+
private_constant :THREAD_KEY, :SCOPE_THREAD_KEY
|
|
22
20
|
|
|
23
21
|
# Frozen regex — only the verb regex remains; table extraction uses index+slice.
|
|
24
22
|
SQL_VERB_RE = /\A(\w+)/i
|
|
@@ -81,21 +79,10 @@ module RailsOtelContext
|
|
|
81
79
|
|
|
82
80
|
# Subscriber for sql.active_record notifications.
|
|
83
81
|
class Subscriber
|
|
84
|
-
def
|
|
85
|
-
@threshold = RailsOtelContext.configuration.slow_query_threshold_ms
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def start(_name, id, payload)
|
|
82
|
+
def start(_name, _id, payload)
|
|
89
83
|
ar_name = payload[:name]
|
|
90
84
|
return if ar_name == 'SCHEMA' || ar_name&.start_with?('CACHE')
|
|
91
85
|
|
|
92
|
-
# Set up slow-query timing regardless of whether we can map a model name,
|
|
93
|
-
# so that raw SQL (connection.execute, SELECT SLEEP, etc.) still sets db.slow.
|
|
94
|
-
if @threshold
|
|
95
|
-
Thread.current[TIMING_ID_KEY] = id
|
|
96
|
-
Thread.current[TIMING_START_KEY] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
86
|
ctx = if ar_name.nil? || ar_name == 'SQL'
|
|
100
87
|
ActiveRecordContext.parse_sql_context(payload[:sql])
|
|
101
88
|
else
|
|
@@ -124,15 +111,7 @@ module RailsOtelContext
|
|
|
124
111
|
ActiveRecordContext.apply_to_span(OpenTelemetry::Trace.current_span, ctx)
|
|
125
112
|
end
|
|
126
113
|
|
|
127
|
-
def finish(_name,
|
|
128
|
-
if @threshold && Thread.current[TIMING_ID_KEY].equal?(id)
|
|
129
|
-
elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - Thread.current[TIMING_START_KEY]) * 1000
|
|
130
|
-
Thread.current[TIMING_ID_KEY] = nil
|
|
131
|
-
if elapsed_ms >= @threshold
|
|
132
|
-
span = OpenTelemetry::Trace.current_span
|
|
133
|
-
span.set_attribute(DB_SLOW_ATTR, true) if span.context.valid?
|
|
134
|
-
end
|
|
135
|
-
end
|
|
114
|
+
def finish(_name, _id, _payload)
|
|
136
115
|
ensure
|
|
137
116
|
Thread.current[THREAD_KEY] = nil
|
|
138
117
|
end
|
|
@@ -196,8 +175,6 @@ module RailsOtelContext
|
|
|
196
175
|
def clear!
|
|
197
176
|
Thread.current[THREAD_KEY] = nil
|
|
198
177
|
Thread.current[SCOPE_THREAD_KEY] = nil
|
|
199
|
-
Thread.current[TIMING_ID_KEY] = nil
|
|
200
|
-
Thread.current[TIMING_START_KEY] = nil
|
|
201
178
|
end
|
|
202
179
|
|
|
203
180
|
# Test helpers: set AR context directly for unit tests.
|
|
@@ -64,7 +64,7 @@ module RailsOtelContext
|
|
|
64
64
|
define_method(method_name) do |*args, &block|
|
|
65
65
|
return super(*args, &block) if Thread.current[reentrancy_key]
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
site = mod.call_site_for_app
|
|
68
68
|
statement = args.first.is_a?(String) ? args.first : nil
|
|
69
69
|
|
|
70
70
|
tracer = OpenTelemetry.tracer_provider.tracer('rails-otel-context-clickhouse')
|
|
@@ -76,7 +76,7 @@ module RailsOtelContext
|
|
|
76
76
|
span.set_attribute('db.statement', statement) if statement
|
|
77
77
|
|
|
78
78
|
result = super(*args, &block)
|
|
79
|
-
mod.
|
|
79
|
+
mod.apply_call_site_to_span(span, site)
|
|
80
80
|
result
|
|
81
81
|
end
|
|
82
82
|
ensure
|
|
@@ -24,47 +24,35 @@ module RailsOtelContext
|
|
|
24
24
|
def build_patch_module
|
|
25
25
|
mod = Module.new do
|
|
26
26
|
class << self
|
|
27
|
+
include RailsOtelContext::SourceLocation
|
|
28
|
+
|
|
27
29
|
attr_accessor :app_root
|
|
28
30
|
|
|
29
31
|
def configure(app_root:)
|
|
30
32
|
@app_root = app_root.to_s
|
|
31
33
|
end
|
|
32
|
-
|
|
33
|
-
def source_location_for_app
|
|
34
|
-
return unless Thread.respond_to?(:each_caller_location)
|
|
35
|
-
|
|
36
|
-
Thread.each_caller_location do |location|
|
|
37
|
-
path = location.absolute_path || location.path
|
|
38
|
-
next unless path&.start_with?(app_root)
|
|
39
|
-
next if path.include?('/gems/')
|
|
40
|
-
|
|
41
|
-
return [path.delete_prefix("#{app_root}/"), location.lineno]
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
nil
|
|
45
|
-
end
|
|
46
34
|
end
|
|
47
35
|
|
|
48
36
|
define_method(:call) do |command, redis_config, &block|
|
|
49
|
-
|
|
50
|
-
return super(command, redis_config, &block) unless
|
|
37
|
+
site = mod.call_site_for_app
|
|
38
|
+
return super(command, redis_config, &block) unless site
|
|
51
39
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
) do
|
|
40
|
+
attrs = { 'code.namespace' => site[:class_name], 'code.filepath' => site[:filepath] }
|
|
41
|
+
attrs['code.function'] = site[:method_name] if site[:method_name]
|
|
42
|
+
attrs['code.lineno'] = site[:lineno] if site[:lineno]
|
|
43
|
+
OpenTelemetry::Instrumentation::Redis.with_attributes(attrs) do
|
|
56
44
|
super(command, redis_config, &block)
|
|
57
45
|
end
|
|
58
46
|
end
|
|
59
47
|
|
|
60
48
|
define_method(:call_pipelined) do |commands, redis_config, &block|
|
|
61
|
-
|
|
62
|
-
return super(commands, redis_config, &block) unless
|
|
49
|
+
site = mod.call_site_for_app
|
|
50
|
+
return super(commands, redis_config, &block) unless site
|
|
63
51
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
) do
|
|
52
|
+
attrs = { 'code.namespace' => site[:class_name], 'code.filepath' => site[:filepath] }
|
|
53
|
+
attrs['code.function'] = site[:method_name] if site[:method_name]
|
|
54
|
+
attrs['code.lineno'] = site[:lineno] if site[:lineno]
|
|
55
|
+
OpenTelemetry::Instrumentation::Redis.with_attributes(attrs) do
|
|
68
56
|
super(commands, redis_config, &block)
|
|
69
57
|
end
|
|
70
58
|
end
|
|
@@ -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,20 +38,38 @@ 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
|
-
@
|
|
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
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
def on_start(span, _parent_context)
|
|
48
48
|
apply_call_context(span)
|
|
49
|
-
apply_request_context(span)
|
|
49
|
+
apply_request_context(span)
|
|
50
50
|
apply_db_context(span)
|
|
51
51
|
apply_custom_attributes(span) if @custom_span_attributes
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
def on_finish(
|
|
54
|
+
def on_finish(span)
|
|
55
|
+
return unless @slow_query_threshold_ms
|
|
56
|
+
return unless span.respond_to?(:attributes) && span.attributes&.key?('db.system')
|
|
57
|
+
|
|
58
|
+
start_ns = span.start_timestamp
|
|
59
|
+
end_ns = span.end_timestamp
|
|
60
|
+
return unless start_ns && end_ns
|
|
61
|
+
|
|
62
|
+
duration_ms = (end_ns - start_ns) / 1_000_000.0
|
|
63
|
+
return unless duration_ms >= @slow_query_threshold_ms
|
|
64
|
+
|
|
65
|
+
# span.recording? is false here — the span has finished and current_span
|
|
66
|
+
# has reverted to the HTTP parent. Write directly to the backing attributes
|
|
67
|
+
# hash so db.slow lands on the actual DB span, not the HTTP parent.
|
|
68
|
+
attrs = span.instance_variable_get(:@attributes)
|
|
69
|
+
attrs.store(ActiveRecordContext::DB_SLOW_ATTR, true) if attrs.respond_to?(:store)
|
|
70
|
+
rescue StandardError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
55
73
|
|
|
56
74
|
def force_flush(timeout: nil); end
|
|
57
75
|
|
|
@@ -60,34 +78,39 @@ module RailsOtelContext
|
|
|
60
78
|
private
|
|
61
79
|
|
|
62
80
|
def apply_call_context(span)
|
|
63
|
-
#
|
|
81
|
+
# Explicit override: app code called FrameContext.with_frame (or Frameable).
|
|
82
|
+
# O(1) — no stack walk. Takes priority over automatic detection.
|
|
64
83
|
pushed = FrameContext.current
|
|
65
84
|
if pushed
|
|
66
85
|
span.set_attribute('code.namespace', pushed[:class_name])
|
|
67
|
-
span.set_attribute('code.function',
|
|
86
|
+
span.set_attribute('code.function', pushed[:method_name]) if pushed[:method_name]
|
|
68
87
|
return
|
|
69
88
|
end
|
|
70
89
|
|
|
71
|
-
#
|
|
90
|
+
# Default: walk the call stack to find the nearest app-code frame.
|
|
72
91
|
return unless Thread.respond_to?(:each_caller_location)
|
|
73
92
|
|
|
74
93
|
site = call_site_for_app
|
|
75
94
|
return unless site
|
|
76
95
|
|
|
77
96
|
span.set_attribute('code.namespace', site[:class_name])
|
|
78
|
-
span.set_attribute('code.function',
|
|
97
|
+
span.set_attribute('code.function', site[:method_name]) if site[:method_name]
|
|
79
98
|
return unless site[:lineno]
|
|
80
99
|
|
|
81
100
|
span.set_attribute('code.filepath', site[:filepath])
|
|
82
|
-
span.set_attribute('code.lineno',
|
|
101
|
+
span.set_attribute('code.lineno', site[:lineno])
|
|
83
102
|
end
|
|
84
103
|
|
|
85
104
|
def apply_request_context(span)
|
|
86
105
|
controller, action = RequestContext.fetch
|
|
87
|
-
|
|
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
|
|
88
111
|
|
|
89
|
-
|
|
90
|
-
span.set_attribute(
|
|
112
|
+
job = RequestContext.job
|
|
113
|
+
span.set_attribute(SPAN_JOB_ATTR, job) if job
|
|
91
114
|
end
|
|
92
115
|
|
|
93
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,34 +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
|
-
RailsOtelContext::
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
)
|
|
45
|
+
RailsOtelContext::RequestContext.set(
|
|
46
|
+
controller: self.class.name,
|
|
47
|
+
action: action_name
|
|
48
|
+
)
|
|
49
|
+
block.call
|
|
50
|
+
ensure
|
|
51
|
+
RailsOtelContext::RequestContext.clear!
|
|
48
52
|
end
|
|
49
53
|
end
|
|
50
54
|
end
|
|
51
55
|
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
initializer 'rails_otel_context.
|
|
55
|
-
ActiveSupport.on_load(:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
)
|
|
62
|
-
block.call
|
|
63
|
-
ensure
|
|
64
|
-
RailsOtelContext::RequestContext.clear!
|
|
65
|
-
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!
|
|
66
65
|
end
|
|
67
66
|
end
|
|
68
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
|