rails-otel-context 0.9.7 → 0.9.9
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 +77 -2
- data/lib/rails_otel_context/activerecord_context.rb +57 -9
- data/lib/rails_otel_context/adapters/clickhouse.rb +105 -20
- data/lib/rails_otel_context/adapters/connection_pool.rb +62 -0
- data/lib/rails_otel_context/adapters.rb +2 -0
- data/lib/rails_otel_context/body_capture.rb +123 -0
- data/lib/rails_otel_context/call_context_processor.rb +20 -3
- data/lib/rails_otel_context/configuration.rb +3 -1
- data/lib/rails_otel_context/version.rb +1 -1
- data/lib/rails_otel_context.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4eb35396c5cddc8f420624441b7f2bdacf2d1ef005510db0855cb9803cfb4635
|
|
4
|
+
data.tar.gz: e8a3ffbb994abad920bf7ccbc66903777d380952711237ca4ce0cd7ef153c7c0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b1c75e3474d1d922f96427658daafbe73b7ca41e9f8f59a6654f7cc2ef4576a7d055287d1fb95b75161a1c178848350672f7b05f798ef080aef50236d6d36824
|
|
7
|
+
data.tar.gz: e2708c7b6dfa266cd62f66d7b302f54aa181108b47fa7fc589fe5e5a0645a42bab0a4d73753f3fd3c326df24c99280052b025808995cda86815662d7848bb573
|
data/README.md
CHANGED
|
@@ -76,7 +76,7 @@ DB spans additionally get:
|
|
|
76
76
|
|
|
77
77
|
## Configuration
|
|
78
78
|
|
|
79
|
-
Zero configuration gets you everything above. The optional initializer adds span naming
|
|
79
|
+
Zero configuration gets you everything above. The optional initializer adds span naming, slow-query detection, and opts into the heavier adapters:
|
|
80
80
|
|
|
81
81
|
```ruby
|
|
82
82
|
# config/initializers/rails_otel_context.rb
|
|
@@ -115,6 +115,11 @@ RailsOtelContext.configure do |c|
|
|
|
115
115
|
|
|
116
116
|
# Attach any per-request context to every span
|
|
117
117
|
c.custom_span_attributes = -> { { 'tenant' => Current.tenant } if Current.tenant }
|
|
118
|
+
|
|
119
|
+
# Opt-in adapters — disabled by default because they patch third-party classes
|
|
120
|
+
# and add a span per call. Turn on what you actually use.
|
|
121
|
+
c.clickhouse_enabled = true # instrument click_house gem queries
|
|
122
|
+
c.connection_pool_tracing_enabled = true # span per AR connection checkout
|
|
118
123
|
end
|
|
119
124
|
```
|
|
120
125
|
|
|
@@ -222,7 +227,13 @@ end
|
|
|
222
227
|
|
|
223
228
|
## Redis and ClickHouse
|
|
224
229
|
|
|
225
|
-
Redis
|
|
230
|
+
Redis is enabled by default. ClickHouse requires opt-in:
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
c.clickhouse_enabled = true
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Both get the same `code.*` attributes pointing to the app-code frame that issued the call:
|
|
226
237
|
|
|
227
238
|
```json
|
|
228
239
|
{
|
|
@@ -237,6 +248,70 @@ Redis and ClickHouse spans get the same `code.*` attributes pointing to the app-
|
|
|
237
248
|
}
|
|
238
249
|
```
|
|
239
250
|
|
|
251
|
+
### ClickHouse span naming
|
|
252
|
+
|
|
253
|
+
ClickHouse spans follow the OTel DB convention: `"{VERB} {table}"`. No configuration required — this happens automatically when the ClickHouse adapter is active.
|
|
254
|
+
|
|
255
|
+
| Query | Span name |
|
|
256
|
+
|---|---|
|
|
257
|
+
| `SELECT * FROM events` | `SELECT events` |
|
|
258
|
+
| `INSERT INTO analytics.page_views ...` | `INSERT page_views` |
|
|
259
|
+
| `OPTIMIZE TABLE logs` | `OPTIMIZE clickhouse` (no FROM — falls back gracefully) |
|
|
260
|
+
|
|
261
|
+
Schema-qualified tables (`db.table`) are supported: `db.name` gets the schema, `db.sql.table` gets the bare table name.
|
|
262
|
+
|
|
263
|
+
If you've configured a `span_name_formatter`, it runs on ClickHouse spans too — same formatter, no extra wiring. The original OTel name is preserved in `l9.orig.name`.
|
|
264
|
+
|
|
265
|
+
## Capturing request and response bodies
|
|
266
|
+
|
|
267
|
+
`BodyCapture` is a Rack middleware that attaches request and response bodies to the active OTel span. It's opt-in — add it to your middleware stack:
|
|
268
|
+
|
|
269
|
+
```ruby
|
|
270
|
+
# config/application.rb (or an initializer, after OTel is configured)
|
|
271
|
+
config.middleware.use RailsOtelContext::BodyCapture
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
That's the zero-config path. Bodies land on `http.request.body` and `http.response.body`, capped at 8 KB, for `application/json`, `application/xml`, and `text/plain` content types. `/health`, `/ready`, and `/metrics` are excluded by default.
|
|
275
|
+
|
|
276
|
+
The option that matters most in production:
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
config.middleware.use RailsOtelContext::BodyCapture, on_error_only: true
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
With `on_error_only: true`, the response body is never buffered on the success path — zero overhead for 2xx. Bodies are only captured when status >= 400, which is exactly when you need them.
|
|
283
|
+
|
|
284
|
+
Full options:
|
|
285
|
+
|
|
286
|
+
| Option | Default | Description |
|
|
287
|
+
|---|---|---|
|
|
288
|
+
| `on_error_only:` | `false` | Capture only on 4xx/5xx |
|
|
289
|
+
| `max_bytes:` | `8192` | Truncate bodies above this size |
|
|
290
|
+
| `content_types:` | `%w[application/json application/xml text/plain]` | Allowlist |
|
|
291
|
+
| `include_paths:` | `nil` | Restrict capture to these path prefixes |
|
|
292
|
+
| `exclude_paths:` | `%w[/health /ready /metrics]` | Skip these path prefixes |
|
|
293
|
+
|
|
294
|
+
## Connection pool tracing
|
|
295
|
+
|
|
296
|
+
When you're chasing timeouts or thread starvation, knowing that `checkout` spent 200ms waiting for a connection is the difference between guessing and knowing. Both `clickhouse_enabled` and `connection_pool_tracing_enabled` are off by default — they patch third-party classes and add a span per call, which is overhead you should choose consciously. Enable it:
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
RailsOtelContext.configure do |c|
|
|
300
|
+
c.connection_pool_tracing_enabled = true
|
|
301
|
+
end
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Each `checkout` call gets its own span with pool state at the moment of acquisition:
|
|
305
|
+
|
|
306
|
+
| Attribute | Description |
|
|
307
|
+
|---|---|
|
|
308
|
+
| `db.pool.size` | Total pool capacity |
|
|
309
|
+
| `db.pool.busy` | Connections currently checked out |
|
|
310
|
+
| `db.pool.idle` | Connections available |
|
|
311
|
+
| `db.pool.waiting` | Threads queued waiting for a connection |
|
|
312
|
+
|
|
313
|
+
Spans inside transactions and `with_connection` blocks are skipped — a pinned connection is already held, so there's nothing to measure.
|
|
314
|
+
|
|
240
315
|
## Performance
|
|
241
316
|
|
|
242
317
|
`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.
|
|
@@ -12,11 +12,12 @@ module RailsOtelContext
|
|
|
12
12
|
# Relation#exec_queries. This handles lazy scopes like
|
|
13
13
|
# Transaction.recent_completed.to_a where the scope method returns before
|
|
14
14
|
# SQL fires.
|
|
15
|
-
module ActiveRecordContext
|
|
16
|
-
THREAD_KEY
|
|
17
|
-
SCOPE_THREAD_KEY
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
module ActiveRecordContext # rubocop:disable Metrics/ModuleLength
|
|
16
|
+
THREAD_KEY = :_rails_otel_ctx_ar
|
|
17
|
+
SCOPE_THREAD_KEY = :_rails_otel_ctx_scope
|
|
18
|
+
PENDING_PREPARE_KEY = :_rails_otel_ctx_pending_prepare_spans
|
|
19
|
+
DB_SLOW_ATTR = 'db.slow'
|
|
20
|
+
private_constant :THREAD_KEY, :SCOPE_THREAD_KEY, :PENDING_PREPARE_KEY
|
|
20
21
|
|
|
21
22
|
# Frozen regex — only the verb regex remains; table extraction uses index+slice.
|
|
22
23
|
SQL_VERB_RE = /\A(\w+)/i
|
|
@@ -102,18 +103,29 @@ module RailsOtelContext
|
|
|
102
103
|
ctx[:async] = true if payload[:async]
|
|
103
104
|
Thread.current[THREAD_KEY] = ctx
|
|
104
105
|
|
|
106
|
+
return unless defined?(OpenTelemetry::Trace)
|
|
107
|
+
|
|
105
108
|
# Enrich the current span directly. When OTel instruments via driver-level
|
|
106
109
|
# prepend (Trilogy, PG, Mysql2), the span is created BEFORE this notification
|
|
107
110
|
# fires, so CallContextProcessor#on_start sees nil AR context. Applying here
|
|
108
111
|
# fixes those spans after the fact.
|
|
109
|
-
return unless defined?(OpenTelemetry::Trace)
|
|
110
|
-
|
|
111
112
|
ActiveRecordContext.apply_to_span(OpenTelemetry::Trace.current_span, ctx)
|
|
113
|
+
|
|
114
|
+
# Retroactively enrich any PREPARE spans that finished before this notification
|
|
115
|
+
# fired. PG's prepared-statement flow sends PREPARE then EXECUTE as separate wire
|
|
116
|
+
# operations; the PREPARE span finishes before sql.active_record starts, so it
|
|
117
|
+
# never sees AR context. CallContextProcessor#on_finish stashes those spans here.
|
|
118
|
+
pending = Thread.current[PENDING_PREPARE_KEY]
|
|
119
|
+
return unless pending
|
|
120
|
+
|
|
121
|
+
pending.each { |s| ActiveRecordContext.retroactively_apply_to_span(s, ctx) }
|
|
122
|
+
Thread.current[PENDING_PREPARE_KEY] = nil
|
|
112
123
|
end
|
|
113
124
|
|
|
114
125
|
def finish(_name, _id, _payload)
|
|
115
126
|
ensure
|
|
116
127
|
Thread.current[THREAD_KEY] = nil
|
|
128
|
+
Thread.current[PENDING_PREPARE_KEY] = nil # clear any leftovers from skipped notifications
|
|
117
129
|
end
|
|
118
130
|
end
|
|
119
131
|
|
|
@@ -173,8 +185,16 @@ module RailsOtelContext
|
|
|
173
185
|
end
|
|
174
186
|
|
|
175
187
|
def clear!
|
|
176
|
-
Thread.current[THREAD_KEY]
|
|
177
|
-
Thread.current[SCOPE_THREAD_KEY]
|
|
188
|
+
Thread.current[THREAD_KEY] = nil
|
|
189
|
+
Thread.current[SCOPE_THREAD_KEY] = nil
|
|
190
|
+
Thread.current[PENDING_PREPARE_KEY] = nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Called from CallContextProcessor#on_finish when a PREPARE span finishes
|
|
194
|
+
# before its sql.active_record notification. Stashed spans are flushed by
|
|
195
|
+
# Subscriber#start when the notification fires.
|
|
196
|
+
def stash_prepare_span(span)
|
|
197
|
+
(Thread.current[PENDING_PREPARE_KEY] ||= []) << span
|
|
178
198
|
end
|
|
179
199
|
|
|
180
200
|
# Test helpers: set AR context directly for unit tests.
|
|
@@ -186,6 +206,34 @@ module RailsOtelContext
|
|
|
186
206
|
Thread.current[SCOPE_THREAD_KEY] = scope_name
|
|
187
207
|
end
|
|
188
208
|
|
|
209
|
+
# Retroactively applies AR context to a finished span (e.g. PREPARE spans that
|
|
210
|
+
# finished before sql.active_record fired). Uses direct @attributes mutation
|
|
211
|
+
# because span.recording? is false — set_attribute would be a no-op.
|
|
212
|
+
def retroactively_apply_to_span(span, ctx)
|
|
213
|
+
attrs = span.instance_variable_get(:@attributes)
|
|
214
|
+
return unless attrs.respond_to?(:store)
|
|
215
|
+
|
|
216
|
+
attrs.store('code.activerecord.model', ctx[:model_name]) if ctx[:model_name]
|
|
217
|
+
attrs.store('code.activerecord.method', ctx[:method_name]) if ctx[:method_name]
|
|
218
|
+
attrs.store('code.activerecord.scope', ctx[:scope_name]) if ctx[:scope_name]
|
|
219
|
+
|
|
220
|
+
formatter = RailsOtelContext.configuration.span_name_formatter
|
|
221
|
+
return unless formatter && span.respond_to?(:name) && attrs.key?('db.system')
|
|
222
|
+
|
|
223
|
+
ar_ctx = ctx.dup
|
|
224
|
+
ar_ctx[:code_namespace] = attrs['code.namespace']
|
|
225
|
+
ar_ctx[:code_function] = attrs['code.function']
|
|
226
|
+
|
|
227
|
+
original_name = span.name
|
|
228
|
+
new_name = formatter.call(original_name, ar_ctx)
|
|
229
|
+
return unless new_name && new_name != original_name
|
|
230
|
+
|
|
231
|
+
attrs.store('l9.orig.name', original_name)
|
|
232
|
+
span.instance_variable_set(:@name, new_name)
|
|
233
|
+
rescue StandardError
|
|
234
|
+
nil
|
|
235
|
+
end
|
|
236
|
+
|
|
189
237
|
# Applies AR context directly to a span. Used by Subscriber#start to enrich spans
|
|
190
238
|
# created by driver-level OTel instrumentation (Trilogy, PG) before our notification
|
|
191
239
|
# subscriber runs. Also reads code.namespace/code.function already set by
|
|
@@ -5,7 +5,17 @@ module RailsOtelContext
|
|
|
5
5
|
module Clickhouse
|
|
6
6
|
module_function
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
# click_house gem v1.x used :query/:select; v2.x uses :select_all/:select_one/:select_value.
|
|
9
|
+
# We list all known variants so install! picks whichever the loaded gem version defines.
|
|
10
|
+
#
|
|
11
|
+
# insert/insert_rows/insert_compact are intentionally absent: in v2.x they all delegate
|
|
12
|
+
# to execute, so patching execute alone is sufficient and avoids wrapping methods whose
|
|
13
|
+
# keyword-argument signatures may differ across gem versions.
|
|
14
|
+
CANDIDATE_METHODS = %i[
|
|
15
|
+
select_all select_one select_value
|
|
16
|
+
execute command
|
|
17
|
+
query select
|
|
18
|
+
].freeze
|
|
9
19
|
REENTRANCY_KEY = :_rails_otel_ctx_clickhouse_instrumenting
|
|
10
20
|
|
|
11
21
|
def install!(app_root:)
|
|
@@ -43,6 +53,50 @@ module RailsOtelContext
|
|
|
43
53
|
@patch_modules[key] ||= build_patch_module(methods)
|
|
44
54
|
end
|
|
45
55
|
|
|
56
|
+
# Maps compound gem method names to their SQL verb for span naming.
|
|
57
|
+
# select_all/select_one/select_value → SELECT.
|
|
58
|
+
METHOD_OP_ALIAS = {
|
|
59
|
+
'SELECT_ALL' => 'SELECT',
|
|
60
|
+
'SELECT_ONE' => 'SELECT',
|
|
61
|
+
'SELECT_VALUE' => 'SELECT'
|
|
62
|
+
}.freeze
|
|
63
|
+
|
|
64
|
+
# Derives a human-readable span name from the SQL statement.
|
|
65
|
+
# Follows OTel DB convention: "{sql_verb} {table}".
|
|
66
|
+
# Falls back to "{method_op} clickhouse" when the statement is absent
|
|
67
|
+
# or cannot be parsed (e.g. raw ClickHouse commands with no FROM clause).
|
|
68
|
+
#
|
|
69
|
+
# Accepts an optional pre-parsed +table_name+ to avoid a second regex scan
|
|
70
|
+
# when the caller already holds the result of parse_table.
|
|
71
|
+
def span_name_for(statement, method_op, table_name: nil)
|
|
72
|
+
effective_op = METHOD_OP_ALIAS.fetch(method_op, method_op)
|
|
73
|
+
return "#{effective_op} clickhouse" unless statement.is_a?(String)
|
|
74
|
+
|
|
75
|
+
sql_verb = statement.lstrip.split(/\s/, 2).first&.upcase
|
|
76
|
+
table_name = parse_table(statement).last if table_name.nil?
|
|
77
|
+
|
|
78
|
+
if sql_verb && table_name
|
|
79
|
+
"#{sql_verb} #{table_name}"
|
|
80
|
+
elsif sql_verb
|
|
81
|
+
"#{sql_verb} clickhouse"
|
|
82
|
+
else
|
|
83
|
+
"#{effective_op} clickhouse"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Returns [db_name, table_name] extracted from the SQL statement.
|
|
88
|
+
# db_name is the schema prefix (e.g. "mailgun_analytics"), nil when absent.
|
|
89
|
+
# table_name is the bare table (e.g. "mailgun_events"), nil when not found.
|
|
90
|
+
def parse_table(statement)
|
|
91
|
+
return [nil, nil] unless statement.is_a?(String)
|
|
92
|
+
|
|
93
|
+
qualified = statement.match(/(?:\bFROM\b|\bINTO\b|\bUPDATE\b)\s+([\w.]+)/i)&.captures&.first
|
|
94
|
+
return [nil, nil] unless qualified
|
|
95
|
+
|
|
96
|
+
parts = qualified.split('.')
|
|
97
|
+
parts.size > 1 ? [parts[0..-2].join('.'), parts.last] : [nil, parts.first]
|
|
98
|
+
end
|
|
99
|
+
|
|
46
100
|
def build_patch_module(methods)
|
|
47
101
|
mod = Module.new do
|
|
48
102
|
class << self
|
|
@@ -55,32 +109,63 @@ module RailsOtelContext
|
|
|
55
109
|
end
|
|
56
110
|
end
|
|
57
111
|
|
|
58
|
-
# AR context and span renaming handled by CallContextProcessor.apply_db_context.
|
|
59
112
|
reentrancy_key = RailsOtelContext::Adapters::Clickhouse::REENTRANCY_KEY
|
|
60
113
|
|
|
61
114
|
methods.each do |method_name|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return super(*args, &block) if Thread.current[reentrancy_key]
|
|
115
|
+
method_op = RailsOtelContext::Adapters::Clickhouse::METHOD_OP_ALIAS
|
|
116
|
+
.fetch(method_name.to_s.upcase, method_name.to_s.upcase)
|
|
117
|
+
.freeze
|
|
66
118
|
|
|
67
|
-
|
|
68
|
-
|
|
119
|
+
define_method(method_name) do |*args, **kwargs, &block|
|
|
120
|
+
return super(*args, **kwargs, &block) if Thread.current[reentrancy_key]
|
|
69
121
|
|
|
70
|
-
tracer = OpenTelemetry.tracer_provider.tracer('rails-otel-context-clickhouse')
|
|
71
122
|
Thread.current[reentrancy_key] = true
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
123
|
+
begin
|
|
124
|
+
site = mod.call_site_for_app
|
|
125
|
+
statement = args.first.is_a?(String) ? args.first : nil
|
|
126
|
+
|
|
127
|
+
# Avoid a second regex scan: pass pre-parsed table_name to span_name_for,
|
|
128
|
+
# and reuse db_name for the db.name attribute.
|
|
129
|
+
db_name, table_name = RailsOtelContext::Adapters::Clickhouse.parse_table(statement)
|
|
130
|
+
sql_verb = statement ? statement.lstrip.split(/\s/, 2).first&.upcase || method_op : method_op
|
|
131
|
+
span_name = RailsOtelContext::Adapters::Clickhouse.span_name_for(
|
|
132
|
+
statement, method_op, table_name: table_name
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
tracer = OpenTelemetry.tracer_provider.tracer('rails-otel-context-clickhouse')
|
|
136
|
+
tracer.in_span(span_name, kind: :client) do |span|
|
|
137
|
+
span.set_attribute('db.system', 'clickhouse')
|
|
138
|
+
span.set_attribute('db.operation', sql_verb)
|
|
139
|
+
span.set_attribute('db.statement', statement) if statement
|
|
140
|
+
span.set_attribute('db.name', db_name) if db_name
|
|
141
|
+
span.set_attribute('db.sql.table', table_name) if table_name
|
|
142
|
+
|
|
143
|
+
result = super(*args, **kwargs, &block)
|
|
144
|
+
mod.apply_call_site_to_span(span, site)
|
|
145
|
+
|
|
146
|
+
# ClickHouse spans don't fire sql.active_record notifications, so
|
|
147
|
+
# CallContextProcessor#apply_db_context never runs for them.
|
|
148
|
+
# Apply the span_name_formatter here with a synthetic AR-shaped context
|
|
149
|
+
# built from code.namespace/code.function set by apply_call_site_to_span.
|
|
150
|
+
formatter = RailsOtelContext.configuration.span_name_formatter
|
|
151
|
+
code_ns = formatter && span.respond_to?(:attributes) &&
|
|
152
|
+
span.attributes['code.namespace']
|
|
153
|
+
if code_ns
|
|
154
|
+
fn = span.attributes['code.function']
|
|
155
|
+
ar_ctx = { model_name: code_ns, method_name: fn, scope_name: nil,
|
|
156
|
+
code_namespace: code_ns, code_function: fn }
|
|
157
|
+
new_name = formatter.call(span_name, ar_ctx)
|
|
158
|
+
if new_name && new_name != span_name
|
|
159
|
+
span.set_attribute('l9.orig.name', span_name)
|
|
160
|
+
span.name = new_name
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
result
|
|
165
|
+
end
|
|
166
|
+
ensure
|
|
167
|
+
Thread.current[reentrancy_key] = false
|
|
81
168
|
end
|
|
82
|
-
ensure
|
|
83
|
-
Thread.current[reentrancy_key] = false
|
|
84
169
|
end
|
|
85
170
|
end
|
|
86
171
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsOtelContext
|
|
4
|
+
module Adapters
|
|
5
|
+
module ConnectionPool
|
|
6
|
+
TRACER_NAME = 'rails_otel_context'
|
|
7
|
+
SPAN_NAME = 'active_record.connection_checkout'
|
|
8
|
+
private_constant :TRACER_NAME, :SPAN_NAME
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def install!
|
|
13
|
+
return unless defined?(::ActiveRecord::ConnectionAdapters::ConnectionPool)
|
|
14
|
+
|
|
15
|
+
patch_module = patch_module_for
|
|
16
|
+
return if ::ActiveRecord::ConnectionAdapters::ConnectionPool.ancestors.include?(patch_module)
|
|
17
|
+
|
|
18
|
+
::ActiveRecord::ConnectionAdapters::ConnectionPool.prepend(patch_module)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def patch_module_for
|
|
22
|
+
@patch_module ||= build_patch_module
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build_patch_module
|
|
26
|
+
# Capture as locals so define_method closes over them — constants declared
|
|
27
|
+
# private_constant are inaccessible from the anonymous module's def scope.
|
|
28
|
+
tracer_name = TRACER_NAME
|
|
29
|
+
span_name = SPAN_NAME
|
|
30
|
+
# Cached lazily on first checkout — tracer_provider may not be fully
|
|
31
|
+
# configured when install! runs at boot.
|
|
32
|
+
cached_tracer = nil
|
|
33
|
+
|
|
34
|
+
Module.new do
|
|
35
|
+
define_method(:checkout) do |checkout_timeout = @checkout_timeout|
|
|
36
|
+
# Rails 7.2+ fast path: pinned connection bypasses pool wait entirely.
|
|
37
|
+
# No span needed — duration would be ~0ms and it's misleading noise.
|
|
38
|
+
return super(checkout_timeout) if @pinned_connection
|
|
39
|
+
|
|
40
|
+
cached_tracer ||= OpenTelemetry.tracer_provider.tracer(tracer_name)
|
|
41
|
+
|
|
42
|
+
cached_tracer.in_span(span_name) do |span|
|
|
43
|
+
result = super(checkout_timeout)
|
|
44
|
+
|
|
45
|
+
# stat acquires the pool's monitor lock and iterates @connections —
|
|
46
|
+
# acceptable for opt-in diagnostics. Avoid enabling this permanently
|
|
47
|
+
# on high-traffic pools where lock contention is already a concern.
|
|
48
|
+
pool_stat = stat
|
|
49
|
+
span.set_attribute('db.pool.size', pool_stat[:size])
|
|
50
|
+
span.set_attribute('db.pool.busy', pool_stat[:busy])
|
|
51
|
+
span.set_attribute('db.pool.idle', pool_stat[:idle])
|
|
52
|
+
span.set_attribute('db.pool.waiting', pool_stat[:waiting])
|
|
53
|
+
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
private_class_method :patch_module_for, :build_patch_module
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -5,6 +5,7 @@ require 'rails_otel_context/adapters/mysql2'
|
|
|
5
5
|
require 'rails_otel_context/adapters/trilogy'
|
|
6
6
|
require 'rails_otel_context/adapters/redis'
|
|
7
7
|
require 'rails_otel_context/adapters/clickhouse'
|
|
8
|
+
require 'rails_otel_context/adapters/connection_pool'
|
|
8
9
|
|
|
9
10
|
module RailsOtelContext
|
|
10
11
|
module Adapters
|
|
@@ -16,6 +17,7 @@ module RailsOtelContext
|
|
|
16
17
|
Trilogy.install!(app_root: app_root)
|
|
17
18
|
Redis.install!(app_root: app_root) if config.redis_source_enabled
|
|
18
19
|
Clickhouse.install!(app_root: app_root) if config.clickhouse_enabled
|
|
20
|
+
ConnectionPool.install! if config.connection_pool_tracing_enabled
|
|
19
21
|
end
|
|
20
22
|
end
|
|
21
23
|
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsOtelContext
|
|
4
|
+
# Rack middleware that captures request and response bodies as OpenTelemetry
|
|
5
|
+
# span attributes. Works with any OTel Rack instrumentation — the middleware
|
|
6
|
+
# must sit inside (after) the span-creating middleware so current_span is set.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# # config/application.rb (or an initializer after OTel is configured)
|
|
10
|
+
# config.middleware.use RailsOtelContext::BodyCapture
|
|
11
|
+
#
|
|
12
|
+
# # With options:
|
|
13
|
+
# config.middleware.use RailsOtelContext::BodyCapture,
|
|
14
|
+
# on_error_only: true,
|
|
15
|
+
# max_bytes: 4096
|
|
16
|
+
#
|
|
17
|
+
# Span attributes set:
|
|
18
|
+
# http.request.body — captured request body (when content type matches)
|
|
19
|
+
# http.response.body — captured response body (when content type matches)
|
|
20
|
+
class BodyCapture
|
|
21
|
+
DEFAULT_CONTENT_TYPES = %w[application/json application/xml text/plain].freeze
|
|
22
|
+
DEFAULT_EXCLUDE_PATHS = %w[/health /ready /metrics].freeze
|
|
23
|
+
DEFAULT_MAX_BYTES = 8_192
|
|
24
|
+
TRUNCATED_SUFFIX = '...[TRUNCATED]'
|
|
25
|
+
private_constant :DEFAULT_CONTENT_TYPES, :DEFAULT_EXCLUDE_PATHS,
|
|
26
|
+
:DEFAULT_MAX_BYTES, :TRUNCATED_SUFFIX
|
|
27
|
+
|
|
28
|
+
def initialize(app, # rubocop:disable Metrics/ParameterLists
|
|
29
|
+
capture_request: true,
|
|
30
|
+
capture_response: true,
|
|
31
|
+
max_bytes: DEFAULT_MAX_BYTES,
|
|
32
|
+
on_error_only: false,
|
|
33
|
+
content_types: DEFAULT_CONTENT_TYPES,
|
|
34
|
+
include_paths: [],
|
|
35
|
+
exclude_paths: DEFAULT_EXCLUDE_PATHS)
|
|
36
|
+
@app = app
|
|
37
|
+
@capture_request = capture_request
|
|
38
|
+
@capture_response = capture_response
|
|
39
|
+
@max_bytes = max_bytes
|
|
40
|
+
@on_error_only = on_error_only
|
|
41
|
+
@content_types = content_types
|
|
42
|
+
@include_paths = include_paths
|
|
43
|
+
@exclude_paths = exclude_paths
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def call(env)
|
|
47
|
+
return @app.call(env) unless should_capture?(env['PATH_INFO'])
|
|
48
|
+
|
|
49
|
+
request_str = read_request(env)
|
|
50
|
+
status, headers, body = @app.call(env)
|
|
51
|
+
|
|
52
|
+
# With on_error_only, skip body drain on successful responses — no buffering
|
|
53
|
+
# overhead on the happy path. (Synchronous Rack means we know status before
|
|
54
|
+
# deciding to drain, unlike async frameworks that must always buffer.)
|
|
55
|
+
should_record = !@on_error_only || status >= 400
|
|
56
|
+
|
|
57
|
+
if should_record && @capture_response
|
|
58
|
+
body, response_str = drain_response(body, headers)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
set_span_attributes(request_str, response_str) if should_record
|
|
62
|
+
|
|
63
|
+
[status, headers, body]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def should_capture?(path)
|
|
69
|
+
return false if @exclude_paths.any? { |p| path.start_with?(p) }
|
|
70
|
+
return true if @include_paths.empty?
|
|
71
|
+
|
|
72
|
+
@include_paths.any? { |p| path.start_with?(p) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def allowed_content_type?(content_type)
|
|
76
|
+
return false unless content_type && !content_type.empty?
|
|
77
|
+
return true if @content_types.empty?
|
|
78
|
+
|
|
79
|
+
@content_types.any? { |ct| content_type.include?(ct) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def read_request(env)
|
|
83
|
+
return unless @capture_request
|
|
84
|
+
return unless allowed_content_type?(env['CONTENT_TYPE'])
|
|
85
|
+
|
|
86
|
+
input = env['rack.input']
|
|
87
|
+
return unless input
|
|
88
|
+
|
|
89
|
+
raw = input.read
|
|
90
|
+
input.rewind
|
|
91
|
+
cap(raw)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def drain_response(body, headers)
|
|
95
|
+
chunks = []
|
|
96
|
+
body.each { |chunk| chunks << chunk } # rubocop:disable Style/MapIntoArray -- RackBody (Rails 8) has no #map
|
|
97
|
+
body.close if body.respond_to?(:close)
|
|
98
|
+
|
|
99
|
+
content_type = headers['Content-Type'] || headers['content-type']
|
|
100
|
+
body_str = cap(chunks.join) if allowed_content_type?(content_type)
|
|
101
|
+
|
|
102
|
+
[chunks, body_str]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def set_span_attributes(request_str, response_str)
|
|
106
|
+
return unless defined?(OpenTelemetry)
|
|
107
|
+
|
|
108
|
+
span = OpenTelemetry::Trace.current_span
|
|
109
|
+
return unless span.context.valid?
|
|
110
|
+
|
|
111
|
+
span.set_attribute('http.request.body', request_str) if request_str
|
|
112
|
+
span.set_attribute('http.response.body', response_str) if response_str
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def cap(str)
|
|
116
|
+
return nil if str.nil? || str.empty?
|
|
117
|
+
return str if str.bytesize <= @max_bytes
|
|
118
|
+
|
|
119
|
+
# scrub cleans up any incomplete multibyte sequence at the slice boundary
|
|
120
|
+
str.byteslice(0, @max_bytes).scrub + TRUNCATED_SUFFIX
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -52,8 +52,14 @@ module RailsOtelContext
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
def on_finish(span)
|
|
55
|
+
return unless span.respond_to?(:attributes)
|
|
56
|
+
|
|
57
|
+
attrs = span.attributes
|
|
58
|
+
return unless attrs&.key?('db.system')
|
|
59
|
+
|
|
60
|
+
stash_if_prepare_span(span, attrs)
|
|
61
|
+
|
|
55
62
|
return unless @slow_query_threshold_ms
|
|
56
|
-
return unless span.respond_to?(:attributes) && span.attributes&.key?('db.system')
|
|
57
63
|
|
|
58
64
|
start_ns = span.start_timestamp
|
|
59
65
|
end_ns = span.end_timestamp
|
|
@@ -65,8 +71,8 @@ module RailsOtelContext
|
|
|
65
71
|
# span.recording? is false here — the span has finished and current_span
|
|
66
72
|
# has reverted to the HTTP parent. Write directly to the backing attributes
|
|
67
73
|
# hash so db.slow lands on the actual DB span, not the HTTP parent.
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
raw_attrs = span.instance_variable_get(:@attributes)
|
|
75
|
+
raw_attrs.store(ActiveRecordContext::DB_SLOW_ATTR, true) if raw_attrs.respond_to?(:store)
|
|
70
76
|
rescue StandardError
|
|
71
77
|
nil
|
|
72
78
|
end
|
|
@@ -79,6 +85,17 @@ module RailsOtelContext
|
|
|
79
85
|
|
|
80
86
|
private
|
|
81
87
|
|
|
88
|
+
# Stash PREPARE spans so Subscriber#start can retroactively apply AR context
|
|
89
|
+
# once the enclosing sql.active_record notification fires. PG's prepared-statement
|
|
90
|
+
# flow runs PREPARE → EXECUTE as separate wire operations; the PREPARE span
|
|
91
|
+
# finishes before the notification, so on_start never sees AR context for it.
|
|
92
|
+
def stash_if_prepare_span(span, attrs)
|
|
93
|
+
return unless attrs['db.operation'] == 'PREPARE'
|
|
94
|
+
return if attrs.key?('code.activerecord.model')
|
|
95
|
+
|
|
96
|
+
ActiveRecordContext.stash_prepare_span(span)
|
|
97
|
+
end
|
|
98
|
+
|
|
82
99
|
def apply_call_context(span)
|
|
83
100
|
# Explicit override: app code called FrameContext.with_frame (or Frameable).
|
|
84
101
|
# O(1) — no stack walk. Takes priority over automatic detection.
|
|
@@ -4,6 +4,7 @@ module RailsOtelContext
|
|
|
4
4
|
class Configuration
|
|
5
5
|
attr_accessor :redis_source_enabled,
|
|
6
6
|
:clickhouse_enabled,
|
|
7
|
+
:connection_pool_tracing_enabled,
|
|
7
8
|
:span_name_formatter,
|
|
8
9
|
:slow_query_threshold_ms
|
|
9
10
|
|
|
@@ -15,7 +16,8 @@ module RailsOtelContext
|
|
|
15
16
|
|
|
16
17
|
def initialize
|
|
17
18
|
@redis_source_enabled = false
|
|
18
|
-
@clickhouse_enabled =
|
|
19
|
+
@clickhouse_enabled = false
|
|
20
|
+
@connection_pool_tracing_enabled = false
|
|
19
21
|
@span_name_formatter = nil
|
|
20
22
|
@custom_span_attributes = nil
|
|
21
23
|
@request_context_enabled = false
|
data/lib/rails_otel_context.rb
CHANGED
|
@@ -14,6 +14,7 @@ require 'rails_otel_context/adapters'
|
|
|
14
14
|
require 'rails_otel_context/request_context'
|
|
15
15
|
require 'rails_otel_context/frame_context'
|
|
16
16
|
require 'rails_otel_context/call_context_processor'
|
|
17
|
+
require 'rails_otel_context/body_capture'
|
|
17
18
|
require 'rails_otel_context/railtie' if defined?(Rails::Railtie)
|
|
18
19
|
|
|
19
20
|
module RailsOtelContext
|
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.9.
|
|
4
|
+
version: 0.9.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Last9
|
|
@@ -66,10 +66,12 @@ files:
|
|
|
66
66
|
- lib/rails_otel_context/activerecord_context.rb
|
|
67
67
|
- lib/rails_otel_context/adapters.rb
|
|
68
68
|
- lib/rails_otel_context/adapters/clickhouse.rb
|
|
69
|
+
- lib/rails_otel_context/adapters/connection_pool.rb
|
|
69
70
|
- lib/rails_otel_context/adapters/mysql2.rb
|
|
70
71
|
- lib/rails_otel_context/adapters/pg.rb
|
|
71
72
|
- lib/rails_otel_context/adapters/redis.rb
|
|
72
73
|
- lib/rails_otel_context/adapters/trilogy.rb
|
|
74
|
+
- lib/rails_otel_context/body_capture.rb
|
|
73
75
|
- lib/rails_otel_context/call_context_processor.rb
|
|
74
76
|
- lib/rails_otel_context/configuration.rb
|
|
75
77
|
- lib/rails_otel_context/frame_context.rb
|