rails-otel-context 0.5.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +1020 -0
- data/lib/rails_otel_context/activerecord_context.rb +124 -0
- data/lib/rails_otel_context/adapters/clickhouse.rb +133 -0
- data/lib/rails_otel_context/adapters/mysql2.rb +84 -0
- data/lib/rails_otel_context/adapters/pg.rb +76 -0
- data/lib/rails_otel_context/adapters/redis.rb +78 -0
- data/lib/rails_otel_context/adapters/trilogy.rb +65 -0
- data/lib/rails_otel_context/adapters.rb +51 -0
- data/lib/rails_otel_context/call_context_processor.rb +153 -0
- data/lib/rails_otel_context/configuration.rb +54 -0
- data/lib/rails_otel_context/railtie.rb +51 -0
- data/lib/rails_otel_context/request_context.rb +44 -0
- data/lib/rails_otel_context/source_location.rb +21 -0
- data/lib/rails_otel_context/version.rb +5 -0
- data/lib/rails_otel_context.rb +87 -0
- metadata +100 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsOtelContext
|
|
4
|
+
# Extracts ActiveRecord model name, method, and scope from sql.active_record
|
|
5
|
+
# notifications and scope instrumentation.
|
|
6
|
+
#
|
|
7
|
+
# Two mechanisms work together:
|
|
8
|
+
# 1. sql.active_record subscriber — captures model name + AR operation type
|
|
9
|
+
# (e.g., "Transaction Load") at query time via payload[:name].
|
|
10
|
+
# 2. Scope tracking — wraps scope-generated methods to store the scope name
|
|
11
|
+
# on the Relation object, then captures it at materialization time via
|
|
12
|
+
# Relation#exec_queries. This handles lazy scopes like
|
|
13
|
+
# Transaction.recent_completed.to_a where the scope method returns before
|
|
14
|
+
# SQL fires.
|
|
15
|
+
module ActiveRecordContext
|
|
16
|
+
THREAD_KEY = :_rails_otel_ctx_ar
|
|
17
|
+
SCOPE_THREAD_KEY = :_rails_otel_ctx_scope
|
|
18
|
+
private_constant :THREAD_KEY, :SCOPE_THREAD_KEY
|
|
19
|
+
|
|
20
|
+
# Subscriber for sql.active_record notifications.
|
|
21
|
+
class Subscriber
|
|
22
|
+
def start(_name, _id, payload)
|
|
23
|
+
ar_name = payload[:name]
|
|
24
|
+
return unless ar_name
|
|
25
|
+
return if ar_name == 'SCHEMA' || ar_name.start_with?('CACHE') || ar_name == 'SQL'
|
|
26
|
+
|
|
27
|
+
ctx = ActiveRecordContext.parse_ar_name(ar_name)
|
|
28
|
+
return unless ctx
|
|
29
|
+
|
|
30
|
+
# Include scope name if one was captured by RelationScopeCapture
|
|
31
|
+
scope = Thread.current[SCOPE_THREAD_KEY]
|
|
32
|
+
ctx[:scope_name] = scope if scope
|
|
33
|
+
Thread.current[THREAD_KEY] = ctx
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def finish(_name, _id, _payload)
|
|
37
|
+
Thread.current[THREAD_KEY] = nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Wraps scope-generated class methods to store the scope name on the Relation.
|
|
42
|
+
module ScopeNameTracking
|
|
43
|
+
def scope(name, body, &)
|
|
44
|
+
super
|
|
45
|
+
|
|
46
|
+
# Guard against double-wrapping on class reload in development
|
|
47
|
+
@_otel_wrapped_scopes ||= {}
|
|
48
|
+
return if @_otel_wrapped_scopes[name]
|
|
49
|
+
|
|
50
|
+
name_str = name.to_s.freeze
|
|
51
|
+
original = method(name)
|
|
52
|
+
define_singleton_method(name) do |*args|
|
|
53
|
+
relation = original.call(*args)
|
|
54
|
+
if relation.is_a?(::ActiveRecord::Relation)
|
|
55
|
+
relation.instance_variable_set(:@_otel_scope_name, name_str)
|
|
56
|
+
end
|
|
57
|
+
relation
|
|
58
|
+
end
|
|
59
|
+
@_otel_wrapped_scopes[name] = true
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Captures scope name from Relation at SQL materialization time.
|
|
64
|
+
module RelationScopeCapture
|
|
65
|
+
def exec_queries(&)
|
|
66
|
+
scope_name = instance_variable_get(:@_otel_scope_name)
|
|
67
|
+
Thread.current[SCOPE_THREAD_KEY] = scope_name if scope_name
|
|
68
|
+
super
|
|
69
|
+
ensure
|
|
70
|
+
Thread.current[SCOPE_THREAD_KEY] = nil
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
module_function
|
|
75
|
+
|
|
76
|
+
def install!
|
|
77
|
+
return unless defined?(::ActiveSupport::Notifications)
|
|
78
|
+
return unless defined?(::ActiveRecord::Base)
|
|
79
|
+
|
|
80
|
+
ActiveSupport::Notifications.subscribe('sql.active_record', Subscriber.new)
|
|
81
|
+
::ActiveRecord::Base.extend(ScopeNameTracking)
|
|
82
|
+
::ActiveRecord::Relation.prepend(RelationScopeCapture)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def current
|
|
86
|
+
Thread.current[THREAD_KEY]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def clear!
|
|
90
|
+
Thread.current[THREAD_KEY] = nil
|
|
91
|
+
Thread.current[SCOPE_THREAD_KEY] = nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Test helpers: set AR context directly for unit tests.
|
|
95
|
+
def stub_context(context)
|
|
96
|
+
Thread.current[THREAD_KEY] = context
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def stub_scope(scope_name)
|
|
100
|
+
Thread.current[SCOPE_THREAD_KEY] = scope_name
|
|
101
|
+
end
|
|
102
|
+
private :stub_scope
|
|
103
|
+
|
|
104
|
+
# Parses "Transaction Load" → { model_name: "Transaction", method_name: "Load" }
|
|
105
|
+
def parse_ar_name(name)
|
|
106
|
+
return nil unless name
|
|
107
|
+
|
|
108
|
+
parts = name.split(' ', 2)
|
|
109
|
+
return nil unless parts.size == 2
|
|
110
|
+
|
|
111
|
+
model_name = parts[0]
|
|
112
|
+
method_name = parts[1]
|
|
113
|
+
|
|
114
|
+
return nil if model_name == 'ActiveRecord'
|
|
115
|
+
|
|
116
|
+
{ model_name: model_name, method_name: method_name }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Legacy method for adapter tests.
|
|
120
|
+
def extract(app_root: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
121
|
+
current
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsOtelContext
|
|
4
|
+
module Adapters
|
|
5
|
+
module Clickhouse
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
CANDIDATE_METHODS = %i[query select insert execute command].freeze
|
|
9
|
+
|
|
10
|
+
def install!(app_root:, threshold_ms:)
|
|
11
|
+
begin
|
|
12
|
+
require 'click_house'
|
|
13
|
+
rescue LoadError
|
|
14
|
+
# ClickHouse client gem is optional for consumers.
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
target_clients.each do |klass|
|
|
18
|
+
methods = CANDIDATE_METHODS.select { |method_name| klass.method_defined?(method_name) }
|
|
19
|
+
next if methods.empty?
|
|
20
|
+
|
|
21
|
+
patch_module = patch_module_for(klass, methods)
|
|
22
|
+
patch_module.configure(app_root: app_root, threshold_ms: threshold_ms)
|
|
23
|
+
next if klass.ancestors.include?(patch_module)
|
|
24
|
+
|
|
25
|
+
klass.prepend(patch_module)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def target_clients
|
|
30
|
+
clients = []
|
|
31
|
+
|
|
32
|
+
clients << ::ClickHouse::Client if defined?(::ClickHouse::Client)
|
|
33
|
+
clients << ::ClickHouse::Connection if defined?(::ClickHouse::Connection)
|
|
34
|
+
clients << ::Clickhouse::Client if defined?(::Clickhouse::Client)
|
|
35
|
+
|
|
36
|
+
clients.compact.uniq
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def patch_module_for(klass, methods)
|
|
40
|
+
@patch_modules ||= {}
|
|
41
|
+
key = [klass.name, methods.sort].join(':')
|
|
42
|
+
@patch_modules[key] ||= build_patch_module(methods)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def build_patch_module(methods)
|
|
46
|
+
mod = Module.new do
|
|
47
|
+
class << self
|
|
48
|
+
attr_accessor :app_root, :threshold_ms
|
|
49
|
+
|
|
50
|
+
def configure(app_root:, threshold_ms:)
|
|
51
|
+
@app_root = app_root.to_s
|
|
52
|
+
@threshold_ms = threshold_ms.to_f
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def source_location_for_app
|
|
56
|
+
return unless Thread.respond_to?(:each_caller_location)
|
|
57
|
+
|
|
58
|
+
Thread.each_caller_location do |location|
|
|
59
|
+
path = location.absolute_path || location.path
|
|
60
|
+
next unless path&.start_with?(app_root)
|
|
61
|
+
next if path.include?('/gems/')
|
|
62
|
+
|
|
63
|
+
return [path.delete_prefix("#{app_root}/"), location.lineno]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def activerecord_context
|
|
70
|
+
RailsOtelContext::ActiveRecordContext.extract(app_root: app_root)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
methods.each do |method_name|
|
|
75
|
+
define_method(method_name) do |*args, &block|
|
|
76
|
+
if Thread.current[:rails_otel_context_clickhouse_instrumenting]
|
|
77
|
+
return super(*args, &block)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
source = mod.source_location_for_app
|
|
81
|
+
ar_context = mod.activerecord_context
|
|
82
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
83
|
+
operation = method_name.to_s.upcase
|
|
84
|
+
statement = args.first.is_a?(String) ? args.first : nil
|
|
85
|
+
|
|
86
|
+
tracer = OpenTelemetry.tracer_provider.tracer('rails-otel-context-clickhouse')
|
|
87
|
+
Thread.current[:rails_otel_context_clickhouse_instrumenting] = true
|
|
88
|
+
|
|
89
|
+
tracer.in_span("#{operation} clickhouse", kind: :client) do |span|
|
|
90
|
+
span.set_attribute('db.system', 'clickhouse')
|
|
91
|
+
span.set_attribute('db.operation', operation)
|
|
92
|
+
span.set_attribute('db.statement', statement) if statement
|
|
93
|
+
|
|
94
|
+
# Rename span if formatter is configured and AR context is available
|
|
95
|
+
if ar_context && RailsOtelContext.configuration.span_name_formatter
|
|
96
|
+
begin
|
|
97
|
+
new_name = RailsOtelContext.configuration.span_name_formatter.call(span.name, ar_context)
|
|
98
|
+
span.name = new_name if new_name && new_name != span.name && span.respond_to?(:name=)
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
warn "[RailsOtelContext] Span name formatter error: #{e.message}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
result = super(*args, &block)
|
|
105
|
+
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0
|
|
106
|
+
|
|
107
|
+
if source && duration_ms >= mod.threshold_ms
|
|
108
|
+
span.set_attribute('code.filepath', source[0])
|
|
109
|
+
span.set_attribute('code.lineno', source[1])
|
|
110
|
+
span.set_attribute('db.query.duration_ms', duration_ms.round(1))
|
|
111
|
+
span.set_attribute('db.query.slow_threshold_ms', mod.threshold_ms)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Add ActiveRecord context if available
|
|
115
|
+
if ar_context
|
|
116
|
+
span.set_attribute('code.activerecord.model', ar_context[:model_name]) if ar_context[:model_name]
|
|
117
|
+
span.set_attribute('code.activerecord.method', ar_context[:method_name]) if ar_context[:method_name]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
result
|
|
121
|
+
end
|
|
122
|
+
ensure
|
|
123
|
+
Thread.current[:rails_otel_context_clickhouse_instrumenting] = false
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
mod
|
|
129
|
+
end
|
|
130
|
+
private_class_method :build_patch_module
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsOtelContext
|
|
4
|
+
module Adapters
|
|
5
|
+
module Mysql2
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def install!(app_root:, threshold_ms:)
|
|
9
|
+
return unless defined?(::Mysql2::Client)
|
|
10
|
+
|
|
11
|
+
patch_module = patch_module_for
|
|
12
|
+
patch_module.configure(app_root: app_root, threshold_ms: threshold_ms)
|
|
13
|
+
|
|
14
|
+
return if ::Mysql2::Client.ancestors.include?(patch_module)
|
|
15
|
+
|
|
16
|
+
::Mysql2::Client.prepend(patch_module)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def patch_module_for
|
|
20
|
+
@patch_module ||= build_patch_module
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def build_patch_module
|
|
24
|
+
mod = Module.new do
|
|
25
|
+
class << self
|
|
26
|
+
include RailsOtelContext::SourceLocation
|
|
27
|
+
|
|
28
|
+
attr_accessor :app_root, :threshold_ms
|
|
29
|
+
|
|
30
|
+
def configure(app_root:, threshold_ms:)
|
|
31
|
+
@app_root = app_root.to_s
|
|
32
|
+
@threshold_ms = threshold_ms.to_f
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# AR context and span renaming handled by CallContextProcessor.apply_db_context.
|
|
37
|
+
# Adapters only handle slow query source location tracking.
|
|
38
|
+
define_method(:query) do |sql, options = {}|
|
|
39
|
+
source = mod.source_location_for_app
|
|
40
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
41
|
+
result = super(sql, options)
|
|
42
|
+
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0
|
|
43
|
+
|
|
44
|
+
span = OpenTelemetry::Trace.current_span
|
|
45
|
+
if span.context.valid?
|
|
46
|
+
threshold = RailsOtelContext.configuration.mysql2_slow_query_threshold_ms
|
|
47
|
+
if source && duration_ms >= threshold
|
|
48
|
+
span.set_attribute('code.filepath', source[0])
|
|
49
|
+
span.set_attribute('code.lineno', source[1])
|
|
50
|
+
span.set_attribute('db.query.duration_ms', duration_ms.round(1))
|
|
51
|
+
span.set_attribute('db.query.slow_threshold_ms', threshold)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
result
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
define_method(:prepare) do |sql|
|
|
59
|
+
source = mod.source_location_for_app
|
|
60
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
61
|
+
result = super(sql)
|
|
62
|
+
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0
|
|
63
|
+
|
|
64
|
+
span = OpenTelemetry::Trace.current_span
|
|
65
|
+
if span.context.valid?
|
|
66
|
+
threshold = RailsOtelContext.configuration.mysql2_slow_query_threshold_ms
|
|
67
|
+
if source && duration_ms >= threshold
|
|
68
|
+
span.set_attribute('code.filepath', source[0])
|
|
69
|
+
span.set_attribute('code.lineno', source[1])
|
|
70
|
+
span.set_attribute('db.query.duration_ms', duration_ms.round(1))
|
|
71
|
+
span.set_attribute('db.query.slow_threshold_ms', threshold)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
result
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
mod
|
|
80
|
+
end
|
|
81
|
+
private_class_method :build_patch_module
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsOtelContext
|
|
4
|
+
module Adapters
|
|
5
|
+
module PG
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def install!(app_root:, threshold_ms:)
|
|
9
|
+
return unless defined?(::PG::Connection)
|
|
10
|
+
|
|
11
|
+
methods = exec_methods
|
|
12
|
+
return if methods.empty?
|
|
13
|
+
|
|
14
|
+
patch_module = patch_module_for(methods)
|
|
15
|
+
patch_module.configure(app_root: app_root, threshold_ms: threshold_ms)
|
|
16
|
+
|
|
17
|
+
return if ::PG::Connection.ancestors.include?(patch_module)
|
|
18
|
+
|
|
19
|
+
::PG::Connection.prepend(patch_module)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def exec_methods
|
|
23
|
+
return [] unless defined?(::PG::Constants::EXEC_ISH_METHODS)
|
|
24
|
+
return [] unless defined?(::PG::Constants::EXEC_PREPARED_ISH_METHODS)
|
|
25
|
+
|
|
26
|
+
(::PG::Constants::EXEC_ISH_METHODS + ::PG::Constants::EXEC_PREPARED_ISH_METHODS).uniq
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def patch_module_for(methods)
|
|
30
|
+
@patch_module ||= build_patch_module(methods)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def build_patch_module(methods)
|
|
34
|
+
mod = Module.new do
|
|
35
|
+
class << self
|
|
36
|
+
include RailsOtelContext::SourceLocation
|
|
37
|
+
|
|
38
|
+
attr_accessor :app_root, :threshold_ms
|
|
39
|
+
|
|
40
|
+
def configure(app_root:, threshold_ms:)
|
|
41
|
+
@app_root = app_root.to_s
|
|
42
|
+
@threshold_ms = threshold_ms.to_f
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# AR context and span renaming handled by CallContextProcessor.apply_db_context.
|
|
47
|
+
# Adapters only handle slow query source location tracking.
|
|
48
|
+
methods.each do |method_name|
|
|
49
|
+
define_method(method_name) do |*args, &user_block|
|
|
50
|
+
source = mod.source_location_for_app
|
|
51
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
52
|
+
|
|
53
|
+
super(*args) do |result|
|
|
54
|
+
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0
|
|
55
|
+
|
|
56
|
+
span = OpenTelemetry::Trace.current_span
|
|
57
|
+
threshold = RailsOtelContext.configuration.pg_slow_query_threshold_ms
|
|
58
|
+
if source && duration_ms >= threshold
|
|
59
|
+
span.set_attribute('code.filepath', source[0])
|
|
60
|
+
span.set_attribute('code.lineno', source[1])
|
|
61
|
+
span.set_attribute('db.query.duration_ms', duration_ms.round(1))
|
|
62
|
+
span.set_attribute('db.query.slow_threshold_ms', threshold)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
user_block ? user_block.call(result) : result
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
mod
|
|
72
|
+
end
|
|
73
|
+
private_class_method :build_patch_module
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsOtelContext
|
|
4
|
+
module Adapters
|
|
5
|
+
module Redis
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def install!(app_root:)
|
|
9
|
+
return unless defined?(::RedisClient::Middlewares)
|
|
10
|
+
return unless defined?(::OpenTelemetry::Instrumentation::Redis)
|
|
11
|
+
|
|
12
|
+
patch_module = patch_module_for
|
|
13
|
+
patch_module.configure(app_root: app_root)
|
|
14
|
+
|
|
15
|
+
return if ::RedisClient::Middlewares.ancestors.include?(patch_module)
|
|
16
|
+
|
|
17
|
+
::RedisClient::Middlewares.prepend(patch_module)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def patch_module_for
|
|
21
|
+
@patch_module ||= build_patch_module
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def build_patch_module
|
|
25
|
+
mod = Module.new do
|
|
26
|
+
class << self
|
|
27
|
+
attr_accessor :app_root
|
|
28
|
+
|
|
29
|
+
def configure(app_root:)
|
|
30
|
+
@app_root = app_root.to_s
|
|
31
|
+
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
|
+
end
|
|
47
|
+
|
|
48
|
+
define_method(:call) do |command, redis_config, &block|
|
|
49
|
+
source = mod.source_location_for_app
|
|
50
|
+
return super(command, redis_config, &block) unless source
|
|
51
|
+
|
|
52
|
+
OpenTelemetry::Instrumentation::Redis.with_attributes(
|
|
53
|
+
'code.filepath' => source[0],
|
|
54
|
+
'code.lineno' => source[1]
|
|
55
|
+
) do
|
|
56
|
+
super(command, redis_config, &block)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
define_method(:call_pipelined) do |commands, redis_config, &block|
|
|
61
|
+
source = mod.source_location_for_app
|
|
62
|
+
return super(commands, redis_config, &block) unless source
|
|
63
|
+
|
|
64
|
+
OpenTelemetry::Instrumentation::Redis.with_attributes(
|
|
65
|
+
'code.filepath' => source[0],
|
|
66
|
+
'code.lineno' => source[1]
|
|
67
|
+
) do
|
|
68
|
+
super(commands, redis_config, &block)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
mod
|
|
74
|
+
end
|
|
75
|
+
private_class_method :build_patch_module
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsOtelContext
|
|
4
|
+
module Adapters
|
|
5
|
+
module Trilogy
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def install!(app_root:, threshold_ms:)
|
|
9
|
+
return unless defined?(::Trilogy)
|
|
10
|
+
|
|
11
|
+
patch_module = patch_module_for
|
|
12
|
+
patch_module.configure(app_root: app_root, threshold_ms: threshold_ms)
|
|
13
|
+
|
|
14
|
+
return if ::Trilogy.ancestors.include?(patch_module)
|
|
15
|
+
|
|
16
|
+
::Trilogy.prepend(patch_module)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def patch_module_for
|
|
20
|
+
@patch_module ||= build_patch_module
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def build_patch_module
|
|
24
|
+
mod = Module.new do
|
|
25
|
+
class << self
|
|
26
|
+
include RailsOtelContext::SourceLocation
|
|
27
|
+
|
|
28
|
+
attr_accessor :app_root, :threshold_ms
|
|
29
|
+
|
|
30
|
+
def configure(app_root:, threshold_ms:)
|
|
31
|
+
@app_root = app_root.to_s
|
|
32
|
+
@threshold_ms = threshold_ms.to_f
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# AR context attributes and span renaming are handled by
|
|
37
|
+
# CallContextProcessor.apply_db_context (via sql.active_record notification).
|
|
38
|
+
# This adapter only handles slow query source location tracking.
|
|
39
|
+
define_method(:query) do |sql|
|
|
40
|
+
source = mod.source_location_for_app
|
|
41
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
42
|
+
result = super(sql)
|
|
43
|
+
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0
|
|
44
|
+
|
|
45
|
+
span = OpenTelemetry::Trace.current_span
|
|
46
|
+
if span.context.valid?
|
|
47
|
+
threshold = RailsOtelContext.configuration.trilogy_slow_query_threshold_ms
|
|
48
|
+
if source && duration_ms >= threshold
|
|
49
|
+
span.set_attribute('code.filepath', source[0])
|
|
50
|
+
span.set_attribute('code.lineno', source[1])
|
|
51
|
+
span.set_attribute('db.query.duration_ms', duration_ms.round(1))
|
|
52
|
+
span.set_attribute('db.query.slow_threshold_ms', threshold)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
result
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
mod
|
|
61
|
+
end
|
|
62
|
+
private_class_method :build_patch_module
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails_otel_context/adapters/pg'
|
|
4
|
+
require 'rails_otel_context/adapters/mysql2'
|
|
5
|
+
require 'rails_otel_context/adapters/trilogy'
|
|
6
|
+
require 'rails_otel_context/adapters/redis'
|
|
7
|
+
require 'rails_otel_context/adapters/clickhouse'
|
|
8
|
+
|
|
9
|
+
module RailsOtelContext
|
|
10
|
+
module Adapters
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def install!(app_root:, config: RailsOtelContext.configuration)
|
|
14
|
+
install_pg!(app_root: app_root, config: config)
|
|
15
|
+
install_mysql2!(app_root: app_root, config: config)
|
|
16
|
+
install_trilogy!(app_root: app_root, config: config)
|
|
17
|
+
install_redis!(app_root: app_root, config: config)
|
|
18
|
+
install_clickhouse!(app_root: app_root, config: config)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def install_pg!(app_root:, config:)
|
|
22
|
+
return unless config.pg_slow_query_enabled
|
|
23
|
+
|
|
24
|
+
PG.install!(app_root: app_root, threshold_ms: config.pg_slow_query_threshold_ms)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def install_mysql2!(app_root:, config:)
|
|
28
|
+
return unless config.mysql2_slow_query_enabled
|
|
29
|
+
|
|
30
|
+
Mysql2.install!(app_root: app_root, threshold_ms: config.mysql2_slow_query_threshold_ms)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def install_trilogy!(app_root:, config:)
|
|
34
|
+
return unless config.trilogy_slow_query_enabled
|
|
35
|
+
|
|
36
|
+
Trilogy.install!(app_root: app_root, threshold_ms: config.trilogy_slow_query_threshold_ms)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def install_redis!(app_root:, config:)
|
|
40
|
+
return unless config.redis_source_enabled
|
|
41
|
+
|
|
42
|
+
Redis.install!(app_root: app_root)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def install_clickhouse!(app_root:, config:)
|
|
46
|
+
return unless config.clickhouse_enabled
|
|
47
|
+
|
|
48
|
+
Clickhouse.install!(app_root: app_root, threshold_ms: config.clickhouse_slow_query_threshold_ms)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|