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.
@@ -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