rails-otel-context 0.9.6 → 0.9.8
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/lib/rails_otel_context/activerecord_context.rb +57 -9
- data/lib/rails_otel_context/adapters/clickhouse.rb +90 -7
- data/lib/rails_otel_context/adapters/connection_pool.rb +62 -0
- data/lib/rails_otel_context/adapters/mysql2.rb +3 -4
- data/lib/rails_otel_context/adapters/pg.rb +6 -8
- data/lib/rails_otel_context/adapters/trilogy.rb +3 -4
- data/lib/rails_otel_context/adapters.rb +2 -0
- data/lib/rails_otel_context/body_capture.rb +122 -0
- data/lib/rails_otel_context/call_context_processor.rb +30 -12
- data/lib/rails_otel_context/configuration.rb +2 -0
- data/lib/rails_otel_context/frame_context.rb +15 -4
- data/lib/rails_otel_context/source_location.rb +16 -0
- 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: 0aa66c7dfd0480bd0a9100e00fbea58eaab5ec0153dd5cb4a82b8df840092787
|
|
4
|
+
data.tar.gz: 9743746552b3a934798f2812a61f9d4f72f5c296ba75dae943b9dcb5120e8f5e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 40c59f1db646b5d159e23b36d9f651b4809ee19dc8f797ef9b6a5d261810ccfabc7d50c4460e01ac1b132058e1a28d9312b235d0c66e2f99fa6503f98a5ce4b9
|
|
7
|
+
data.tar.gz: 828f792d02e5ff2c7588d946f8535f5d41f25ad84e55f22854f73e007cb6bf6c39eaf1f05ab40eedae6a37c0d8d5c9e952d127428255221d47bee6d72e51170f
|
|
@@ -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(AR_MODEL_ATTR, ctx[:model_name]) if ctx[:model_name]
|
|
217
|
+
attrs.store(AR_METHOD_ATTR, ctx[:method_name]) if ctx[:method_name]
|
|
218
|
+
attrs.store(AR_SCOPE_ATTR, 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(ORIG_NAME_ATTR, 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,14 @@ 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
|
+
CANDIDATE_METHODS = %i[
|
|
11
|
+
select_all select_one select_value
|
|
12
|
+
insert insert_compact insert_rows
|
|
13
|
+
execute command
|
|
14
|
+
query select
|
|
15
|
+
].freeze
|
|
9
16
|
REENTRANCY_KEY = :_rails_otel_ctx_clickhouse_instrumenting
|
|
10
17
|
|
|
11
18
|
def install!(app_root:)
|
|
@@ -43,6 +50,52 @@ module RailsOtelContext
|
|
|
43
50
|
@patch_modules[key] ||= build_patch_module(methods)
|
|
44
51
|
end
|
|
45
52
|
|
|
53
|
+
# Maps compound gem method names to their SQL verb for span naming.
|
|
54
|
+
# select_all/select_one/select_value → SELECT; insert_* → INSERT.
|
|
55
|
+
METHOD_OP_ALIAS = {
|
|
56
|
+
'SELECT_ALL' => 'SELECT',
|
|
57
|
+
'SELECT_ONE' => 'SELECT',
|
|
58
|
+
'SELECT_VALUE' => 'SELECT',
|
|
59
|
+
'INSERT_COMPACT' => 'INSERT',
|
|
60
|
+
'INSERT_ROWS' => 'INSERT'
|
|
61
|
+
}.freeze
|
|
62
|
+
|
|
63
|
+
# Derives a human-readable span name from the SQL statement.
|
|
64
|
+
# Follows OTel DB convention: "{sql_verb} {table}".
|
|
65
|
+
# Falls back to "{method_op} clickhouse" when the statement is absent
|
|
66
|
+
# or cannot be parsed (e.g. raw ClickHouse commands with no FROM clause).
|
|
67
|
+
#
|
|
68
|
+
# Accepts an optional pre-parsed +table_name+ to avoid a second regex scan
|
|
69
|
+
# when the caller already holds the result of parse_table.
|
|
70
|
+
def span_name_for(statement, method_op, table_name: nil)
|
|
71
|
+
effective_op = METHOD_OP_ALIAS.fetch(method_op, method_op)
|
|
72
|
+
return "#{effective_op} clickhouse" unless statement.is_a?(String)
|
|
73
|
+
|
|
74
|
+
sql_verb = statement.lstrip.split(/\s/, 2).first&.upcase
|
|
75
|
+
table_name = parse_table(statement).last if table_name.nil?
|
|
76
|
+
|
|
77
|
+
if sql_verb && table_name
|
|
78
|
+
"#{sql_verb} #{table_name}"
|
|
79
|
+
elsif sql_verb
|
|
80
|
+
"#{sql_verb} clickhouse"
|
|
81
|
+
else
|
|
82
|
+
"#{effective_op} clickhouse"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Returns [db_name, table_name] extracted from the SQL statement.
|
|
87
|
+
# db_name is the schema prefix (e.g. "mailgun_analytics"), nil when absent.
|
|
88
|
+
# table_name is the bare table (e.g. "mailgun_events"), nil when not found.
|
|
89
|
+
def parse_table(statement)
|
|
90
|
+
return [nil, nil] unless statement.is_a?(String)
|
|
91
|
+
|
|
92
|
+
qualified = statement.match(/(?:\bFROM\b|\bINTO\b|\bUPDATE\b)\s+([\w.]+)/i)&.captures&.first
|
|
93
|
+
return [nil, nil] unless qualified
|
|
94
|
+
|
|
95
|
+
parts = qualified.split('.')
|
|
96
|
+
parts.size > 1 ? [parts[0..-2].join('.'), parts.last] : [nil, parts.first]
|
|
97
|
+
end
|
|
98
|
+
|
|
46
99
|
def build_patch_module(methods)
|
|
47
100
|
mod = Module.new do
|
|
48
101
|
class << self
|
|
@@ -55,11 +108,12 @@ module RailsOtelContext
|
|
|
55
108
|
end
|
|
56
109
|
end
|
|
57
110
|
|
|
58
|
-
# AR context and span renaming handled by CallContextProcessor.apply_db_context.
|
|
59
111
|
reentrancy_key = RailsOtelContext::Adapters::Clickhouse::REENTRANCY_KEY
|
|
60
112
|
|
|
61
113
|
methods.each do |method_name|
|
|
62
|
-
|
|
114
|
+
method_op = RailsOtelContext::Adapters::Clickhouse::METHOD_OP_ALIAS
|
|
115
|
+
.fetch(method_name.to_s.upcase, method_name.to_s.upcase)
|
|
116
|
+
.freeze
|
|
63
117
|
|
|
64
118
|
define_method(method_name) do |*args, &block|
|
|
65
119
|
return super(*args, &block) if Thread.current[reentrancy_key]
|
|
@@ -67,16 +121,45 @@ module RailsOtelContext
|
|
|
67
121
|
site = mod.call_site_for_app
|
|
68
122
|
statement = args.first.is_a?(String) ? args.first : nil
|
|
69
123
|
|
|
124
|
+
# Parse table once — span_name_for accepts the pre-parsed value to skip
|
|
125
|
+
# the internal regex scan, and we reuse db_name for db.name attribute.
|
|
126
|
+
db_name, table_name = RailsOtelContext::Adapters::Clickhouse.parse_table(statement)
|
|
127
|
+
sql_verb = statement ? statement.lstrip.split(/\s/, 2).first&.upcase || method_op : method_op
|
|
128
|
+
span_name = RailsOtelContext::Adapters::Clickhouse.span_name_for(
|
|
129
|
+
statement, method_op, table_name: table_name
|
|
130
|
+
)
|
|
131
|
+
|
|
70
132
|
tracer = OpenTelemetry.tracer_provider.tracer('rails-otel-context-clickhouse')
|
|
71
133
|
Thread.current[reentrancy_key] = true
|
|
72
134
|
|
|
73
|
-
tracer.in_span(
|
|
74
|
-
span.set_attribute('db.system',
|
|
75
|
-
span.set_attribute('db.operation',
|
|
76
|
-
span.set_attribute('db.statement', statement)
|
|
135
|
+
tracer.in_span(span_name, kind: :client) do |span|
|
|
136
|
+
span.set_attribute('db.system', 'clickhouse')
|
|
137
|
+
span.set_attribute('db.operation', sql_verb)
|
|
138
|
+
span.set_attribute('db.statement', statement) if statement
|
|
139
|
+
span.set_attribute('db.name', db_name) if db_name
|
|
140
|
+
span.set_attribute('db.sql.table', table_name) if table_name
|
|
77
141
|
|
|
78
142
|
result = super(*args, &block)
|
|
79
143
|
mod.apply_call_site_to_span(span, site)
|
|
144
|
+
|
|
145
|
+
# ClickHouse spans don't fire sql.active_record notifications, so
|
|
146
|
+
# CallContextProcessor#apply_db_context never runs for them.
|
|
147
|
+
# Apply the span_name_formatter here with a synthetic AR-shaped context
|
|
148
|
+
# built from code.namespace/code.function set by apply_call_site_to_span.
|
|
149
|
+
formatter = RailsOtelContext.configuration.span_name_formatter
|
|
150
|
+
code_ns = formatter && span.respond_to?(:attributes) &&
|
|
151
|
+
span.attributes['code.namespace']
|
|
152
|
+
if code_ns
|
|
153
|
+
fn = span.attributes['code.function']
|
|
154
|
+
ar_ctx = { model_name: code_ns, method_name: fn, scope_name: nil,
|
|
155
|
+
code_namespace: code_ns, code_function: fn }
|
|
156
|
+
new_name = formatter.call(span_name, ar_ctx)
|
|
157
|
+
if new_name && new_name != span_name
|
|
158
|
+
span.set_attribute('l9.orig.name', span_name)
|
|
159
|
+
span.name = new_name
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
80
163
|
result
|
|
81
164
|
end
|
|
82
165
|
ensure
|
|
@@ -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
|
|
@@ -32,12 +32,11 @@ module RailsOtelContext
|
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
#
|
|
35
|
+
# Push call-site into FrameContext BEFORE super so the OTel child span
|
|
36
|
+
# created inside super picks it up via CallContextProcessor#on_start.
|
|
36
37
|
%i[query prepare].each do |method_name|
|
|
37
38
|
define_method(method_name) do |*args|
|
|
38
|
-
|
|
39
|
-
mod.apply_call_site_to_span(OpenTelemetry::Trace.current_span, mod.call_site_for_app)
|
|
40
|
-
result
|
|
39
|
+
mod.with_call_site_frame { super(*args) }
|
|
41
40
|
end
|
|
42
41
|
end
|
|
43
42
|
end
|
|
@@ -42,16 +42,14 @@ module RailsOtelContext
|
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
#
|
|
45
|
+
# Push call-site into FrameContext BEFORE super so the OTel child span
|
|
46
|
+
# created inside super picks it up via CallContextProcessor#on_start.
|
|
46
47
|
methods.each do |method_name|
|
|
47
48
|
define_method(method_name) do |*args, &user_block|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
super(*args) do |result|
|
|
53
|
-
mod.apply_call_site_to_span(OpenTelemetry::Trace.current_span, site)
|
|
54
|
-
user_block ? user_block.call(result) : result
|
|
49
|
+
mod.with_call_site_frame do
|
|
50
|
+
super(*args) do |result|
|
|
51
|
+
user_block ? user_block.call(result) : result
|
|
52
|
+
end
|
|
55
53
|
end
|
|
56
54
|
end
|
|
57
55
|
end
|
|
@@ -32,11 +32,10 @@ module RailsOtelContext
|
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
#
|
|
35
|
+
# Push call-site into FrameContext BEFORE super so the OTel child span
|
|
36
|
+
# created inside super picks it up via CallContextProcessor#on_start.
|
|
36
37
|
define_method(:query) do |sql|
|
|
37
|
-
|
|
38
|
-
mod.apply_call_site_to_span(OpenTelemetry::Trace.current_span, mod.call_site_for_app)
|
|
39
|
-
result
|
|
38
|
+
mod.with_call_site_frame { super(sql) }
|
|
40
39
|
end
|
|
41
40
|
end
|
|
42
41
|
|
|
@@ -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,122 @@
|
|
|
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 = body.map { |chunk| chunk }
|
|
96
|
+
body.close if body.respond_to?(:close)
|
|
97
|
+
|
|
98
|
+
content_type = headers['Content-Type'] || headers['content-type']
|
|
99
|
+
body_str = cap(chunks.join) if allowed_content_type?(content_type)
|
|
100
|
+
|
|
101
|
+
[chunks, body_str]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def set_span_attributes(request_str, response_str)
|
|
105
|
+
return unless defined?(OpenTelemetry)
|
|
106
|
+
|
|
107
|
+
span = OpenTelemetry::Trace.current_span
|
|
108
|
+
return unless span.context.valid?
|
|
109
|
+
|
|
110
|
+
span.set_attribute('http.request.body', request_str) if request_str
|
|
111
|
+
span.set_attribute('http.response.body', response_str) if response_str
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def cap(str)
|
|
115
|
+
return nil if str.nil? || str.empty?
|
|
116
|
+
return str if str.bytesize <= @max_bytes
|
|
117
|
+
|
|
118
|
+
# scrub cleans up any incomplete multibyte sequence at the slice boundary
|
|
119
|
+
str.byteslice(0, @max_bytes).scrub + TRUNCATED_SUFFIX
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
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,20 +85,32 @@ 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.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return
|
|
90
|
-
end
|
|
102
|
+
# DB adapters push filepath/lineno too so the child span gets full call-site info.
|
|
103
|
+
site = FrameContext.current
|
|
104
|
+
unless site
|
|
105
|
+
# Default: walk the call stack to find the nearest app-code frame.
|
|
106
|
+
return unless Thread.respond_to?(:each_caller_location)
|
|
91
107
|
|
|
92
|
-
|
|
93
|
-
|
|
108
|
+
site = call_site_for_app
|
|
109
|
+
end
|
|
110
|
+
set_call_site_attributes(span, site)
|
|
111
|
+
end
|
|
94
112
|
|
|
95
|
-
|
|
113
|
+
def set_call_site_attributes(span, site)
|
|
96
114
|
return unless site
|
|
97
115
|
|
|
98
116
|
span.set_attribute('code.namespace', site[:class_name])
|
|
@@ -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
|
|
|
@@ -16,6 +17,7 @@ module RailsOtelContext
|
|
|
16
17
|
def initialize
|
|
17
18
|
@redis_source_enabled = false
|
|
18
19
|
@clickhouse_enabled = true
|
|
20
|
+
@connection_pool_tracing_enabled = false
|
|
19
21
|
@span_name_formatter = nil
|
|
20
22
|
@custom_span_attributes = nil
|
|
21
23
|
@request_context_enabled = false
|
|
@@ -27,17 +27,19 @@ module RailsOtelContext
|
|
|
27
27
|
class << self
|
|
28
28
|
# Pushes +class_name+/+method_name+ for the duration of the block,
|
|
29
29
|
# restoring whatever was pushed before (supports nesting).
|
|
30
|
-
|
|
30
|
+
# Optional +filepath:+ and +lineno:+ are carried through to the span
|
|
31
|
+
# processor so DB adapter call-site info survives the span lifecycle.
|
|
32
|
+
def with_frame(class_name:, method_name:, filepath: nil, lineno: nil)
|
|
31
33
|
prev = Thread.current[FRAME_KEY]
|
|
32
|
-
Thread.current[FRAME_KEY] =
|
|
34
|
+
Thread.current[FRAME_KEY] = build_frame(class_name, method_name, filepath, lineno)
|
|
33
35
|
yield
|
|
34
36
|
ensure
|
|
35
37
|
Thread.current[FRAME_KEY] = prev
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
# Manual push without a block. Caller must call +pop+ in an ensure.
|
|
39
|
-
def push(class_name:, method_name:)
|
|
40
|
-
Thread.current[FRAME_KEY] =
|
|
41
|
+
def push(class_name:, method_name:, filepath: nil, lineno: nil)
|
|
42
|
+
Thread.current[FRAME_KEY] = build_frame(class_name, method_name, filepath, lineno)
|
|
41
43
|
end
|
|
42
44
|
|
|
43
45
|
# Clears the pushed frame. Pair with +push+ in an ensure block.
|
|
@@ -51,6 +53,15 @@ module RailsOtelContext
|
|
|
51
53
|
end
|
|
52
54
|
|
|
53
55
|
alias clear! pop
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def build_frame(class_name, method_name, filepath, lineno)
|
|
60
|
+
frame = { class_name: class_name, method_name: method_name }
|
|
61
|
+
frame[:filepath] = filepath if filepath
|
|
62
|
+
frame[:lineno] = lineno if lineno
|
|
63
|
+
frame.freeze
|
|
64
|
+
end
|
|
54
65
|
end
|
|
55
66
|
end
|
|
56
67
|
|
|
@@ -46,6 +46,22 @@ module RailsOtelContext
|
|
|
46
46
|
span.set_attribute('code.lineno', site[:lineno]) if site[:lineno]
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
# Wraps a block with the nearest app-code frame pushed into FrameContext.
|
|
50
|
+
# Used by DB adapters to make the call-site available to
|
|
51
|
+
# CallContextProcessor#on_start for the child span created inside the block.
|
|
52
|
+
# Uses with_frame (not push/pop) so nested frames are correctly restored.
|
|
53
|
+
def with_call_site_frame(&)
|
|
54
|
+
site = call_site_for_app
|
|
55
|
+
if site
|
|
56
|
+
FrameContext.with_frame(
|
|
57
|
+
class_name: site[:class_name], method_name: site[:method_name],
|
|
58
|
+
filepath: site[:filepath], lineno: site[:lineno], &
|
|
59
|
+
)
|
|
60
|
+
else
|
|
61
|
+
yield
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
49
65
|
# Legacy helper kept for Redis and ClickHouse adapters that only need filepath + lineno.
|
|
50
66
|
# Migrate those adapters to call_site_for_app + apply_call_site_to_span to remove this.
|
|
51
67
|
def source_location_for_app
|
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.8
|
|
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
|