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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f8ea9eb65d7e405bbd14a39889bbad983edbba5c4bdcce252157d426a2372bc6
4
- data.tar.gz: c70ddac87d64c8359fe82fe142bda62e4968b3429ea6944796b475270310d1d7
3
+ metadata.gz: 4eb35396c5cddc8f420624441b7f2bdacf2d1ef005510db0855cb9803cfb4635
4
+ data.tar.gz: e8a3ffbb994abad920bf7ccbc66903777d380952711237ca4ce0cd7ef153c7c0
5
5
  SHA512:
6
- metadata.gz: cfee7357103d1336be233b9b6724c1b6678117f7fcd3e57dda75ca473a3a608c8f511e7151db29cd9bce34627fcd8d0b482fcdb640812941c960385bf57acadd
7
- data.tar.gz: 15888432fb759223a87cdf35f0dd9ae00c07ac59ef038b06bd49644968996b23fc910b0cca0f0912857bf8a4101ad9d005aa66596cef5b4a1e29de68a12486c8
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 and slow-query detection:
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 and ClickHouse spans get the same `code.*` attributes pointing to the app-code frame that issued the call:
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 = :_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
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] = nil
177
- Thread.current[SCOPE_THREAD_KEY] = nil
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
- CANDIDATE_METHODS = %i[query select insert execute command].freeze
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
- operation = method_name.to_s.upcase.freeze
63
-
64
- define_method(method_name) do |*args, &block|
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
- site = mod.call_site_for_app
68
- statement = args.first.is_a?(String) ? args.first : nil
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
- tracer.in_span("#{operation} clickhouse", kind: :client) do |span|
74
- span.set_attribute('db.system', 'clickhouse')
75
- span.set_attribute('db.operation', operation)
76
- span.set_attribute('db.statement', statement) if statement
77
-
78
- result = super(*args, &block)
79
- mod.apply_call_site_to_span(span, site)
80
- result
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
- attrs = span.instance_variable_get(:@attributes)
69
- attrs.store(ActiveRecordContext::DB_SLOW_ATTR, true) if attrs.respond_to?(:store)
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 = true
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsOtelContext
4
- VERSION = '0.9.7'
4
+ VERSION = '0.9.9'
5
5
  end
@@ -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.7
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