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,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOtelContext
4
+ # SpanProcessor that enriches all spans with the calling Ruby class and method name,
5
+ # and optionally with user-defined custom attributes.
6
+ #
7
+ # Sets two attributes on every span (unless the call stack yields no app-code frame):
8
+ # - code.namespace – the class name, e.g. "OrderService", "InvoiceJob"
9
+ # - code.function – the method name, e.g. "create", "perform"
10
+ #
11
+ # Class names are extracted from the frame label when available (e.g. "User.find"),
12
+ # and inferred from the file-path basename otherwise (e.g. order_service.rb → OrderService).
13
+ #
14
+ # Frames inside gems or outside app_root are always skipped.
15
+ #
16
+ # Custom attributes (configured via +custom_span_attributes+) are applied to every span.
17
+ # The callable must return a Hash (or nil) and must be fast — it runs in the hot path
18
+ # of every span creation. Exceptions in the callable are silently rescued to avoid
19
+ # disrupting application request processing.
20
+ class CallContextProcessor
21
+ SPAN_CONTROLLER_ATTR = 'request.controller'
22
+ SPAN_ACTION_ATTR = 'request.action'
23
+ AR_MODEL_ATTR = 'code.activerecord.model'
24
+ AR_METHOD_ATTR = 'code.activerecord.method'
25
+ AR_SCOPE_ATTR = 'code.activerecord.scope'
26
+ ORIG_NAME_ATTR = 'l9.orig.name'
27
+
28
+ def initialize(app_root:, config: RailsOtelContext.configuration)
29
+ @app_root = app_root.to_s
30
+ @call_context_enabled = config.call_context_enabled
31
+ @request_context_enabled = config.request_context_enabled
32
+ @custom_span_attributes = config.custom_span_attributes
33
+ @custom_span_attributes_enabled = config.custom_span_attributes_enabled
34
+ @span_name_formatter = config.span_name_formatter
35
+ @has_each_caller_location = Thread.respond_to?(:each_caller_location)
36
+ end
37
+
38
+ def on_start(span, _parent_context)
39
+ apply_call_context(span) if @call_context_enabled
40
+ apply_request_context(span) if @request_context_enabled
41
+ apply_db_context(span)
42
+ apply_custom_attributes(span) if @custom_span_attributes
43
+ end
44
+
45
+ def on_finish(_span); end
46
+
47
+ def force_flush(timeout: nil); end
48
+
49
+ def shutdown(timeout: nil); end
50
+
51
+ private
52
+
53
+ def apply_call_context(span)
54
+ return unless @has_each_caller_location
55
+
56
+ context = extract_caller_context
57
+ return unless context
58
+
59
+ span.set_attribute('code.namespace', context[:class_name])
60
+ span.set_attribute('code.function', context[:method_name]) if context[:method_name]
61
+ return unless context[:lineno]
62
+
63
+ span.set_attribute('code.filepath', context[:filepath])
64
+ span.set_attribute('code.lineno', context[:lineno])
65
+ end
66
+
67
+ def apply_request_context(span)
68
+ controller, action = RequestContext.fetch
69
+ return unless controller
70
+
71
+ span.set_attribute(SPAN_CONTROLLER_ATTR, controller)
72
+ span.set_attribute(SPAN_ACTION_ATTR, action) if action
73
+ end
74
+
75
+ def apply_db_context(span)
76
+ base_context = ActiveRecordContext.current
77
+ return unless base_context
78
+
79
+ # Shallow copy to avoid mutating the shared thread-local hash
80
+ ar_context = base_context.dup
81
+ enrich_ar_context(span, ar_context)
82
+ set_ar_attributes(span, ar_context)
83
+ apply_span_name_formatter(span, ar_context)
84
+ rescue StandardError
85
+ # Never let AR context or formatter errors break span processing
86
+ end
87
+
88
+ def enrich_ar_context(span, ar_context)
89
+ return unless span.respond_to?(:attributes)
90
+
91
+ ar_context[:code_namespace] = span.attributes['code.namespace']
92
+ ar_context[:code_function] = span.attributes['code.function']
93
+ end
94
+
95
+ def set_ar_attributes(span, ar_context)
96
+ span.set_attribute(AR_MODEL_ATTR, ar_context[:model_name]) if ar_context[:model_name]
97
+ span.set_attribute(AR_METHOD_ATTR, ar_context[:method_name]) if ar_context[:method_name]
98
+ span.set_attribute(AR_SCOPE_ATTR, ar_context[:scope_name]) if ar_context[:scope_name]
99
+ end
100
+
101
+ def apply_span_name_formatter(span, ar_context)
102
+ return unless @span_name_formatter
103
+
104
+ original_name = span.name
105
+ new_name = @span_name_formatter.call(original_name, ar_context)
106
+ return unless new_name && new_name != original_name && span.respond_to?(:name=)
107
+
108
+ span.set_attribute(ORIG_NAME_ATTR, original_name)
109
+ span.name = new_name
110
+ end
111
+
112
+ def apply_custom_attributes(span)
113
+ return unless @custom_span_attributes_enabled
114
+
115
+ attrs = @custom_span_attributes.call
116
+ return unless attrs.is_a?(Hash) && !attrs.empty?
117
+
118
+ attrs.each do |key, value|
119
+ span.set_attribute(key, value) unless value.nil?
120
+ end
121
+ rescue StandardError
122
+ # Never let a user-supplied callback break span processing.
123
+ end
124
+
125
+ def extract_caller_context
126
+ Thread.each_caller_location do |location|
127
+ path = location.absolute_path || location.path
128
+ next unless path&.start_with?(@app_root)
129
+ next if path.include?('/gems/')
130
+
131
+ label = location.label || ''
132
+ lineno = location.lineno
133
+ filepath = path.delete_prefix("#{@app_root}/")
134
+
135
+ # Try label first: "ClassName.method" or "ClassName#method"
136
+ if label =~ /^([A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*)(\.|\#)/
137
+ class_name = Regexp.last_match(1)
138
+ method_name = label.split(/[.\#]/, 2).last
139
+ &.sub(/^(?:block|rescue|ensure) in /, '')
140
+ return { class_name: class_name, method_name: method_name, lineno: lineno, filepath: filepath }
141
+ end
142
+
143
+ # Fallback: infer class from file-path basename (snake_case → CamelCase)
144
+ class_name = File.basename(path, '.rb').split('_').map(&:capitalize).join
145
+ method_name = label.sub(/^(?:block|rescue|ensure) in /, '')
146
+ return { class_name: class_name, method_name: method_name.empty? ? nil : method_name,
147
+ lineno: lineno, filepath: filepath }
148
+ end
149
+
150
+ nil
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOtelContext
4
+ class Configuration
5
+ attr_accessor :pg_slow_query_enabled,
6
+ :pg_slow_query_threshold_ms,
7
+ :mysql2_slow_query_enabled,
8
+ :mysql2_slow_query_threshold_ms,
9
+ :trilogy_slow_query_enabled,
10
+ :trilogy_slow_query_threshold_ms,
11
+ :redis_source_enabled,
12
+ :clickhouse_enabled,
13
+ :clickhouse_slow_query_threshold_ms,
14
+ :span_name_formatter,
15
+ :call_context_enabled,
16
+ :custom_span_attributes_enabled,
17
+ :request_context_enabled
18
+
19
+ attr_reader :custom_span_attributes
20
+
21
+ def initialize
22
+ @pg_slow_query_enabled = true
23
+ @pg_slow_query_threshold_ms = 200.0
24
+ @mysql2_slow_query_enabled = true
25
+ @mysql2_slow_query_threshold_ms = 200.0
26
+ @trilogy_slow_query_enabled = true
27
+ @trilogy_slow_query_threshold_ms = 200.0
28
+ @redis_source_enabled = false
29
+ @clickhouse_enabled = true
30
+ @clickhouse_slow_query_threshold_ms = 200.0
31
+ @span_name_formatter = nil
32
+ @call_context_enabled = true
33
+ @custom_span_attributes = nil
34
+ @custom_span_attributes_enabled = true
35
+ @request_context_enabled = false
36
+ end
37
+
38
+ # Accepts a callable (lambda/proc) that returns a Hash of string keys to string values.
39
+ # The callable is invoked on every span start, so it must be fast.
40
+ # Returning nil or an empty hash is a no-op.
41
+ #
42
+ # Example:
43
+ # config.custom_span_attributes = -> {
44
+ # { "team" => Current.team } if Current.team.present?
45
+ # }
46
+ def custom_span_attributes=(callable)
47
+ unless callable.nil? || callable.respond_to?(:call)
48
+ raise ArgumentError, 'custom_span_attributes must be a callable (lambda/proc) or nil'
49
+ end
50
+
51
+ @custom_span_attributes = callable
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_otel_context/adapters'
4
+ require 'rails_otel_context/call_context_processor'
5
+
6
+ module RailsOtelContext
7
+ class Railtie < Rails::Railtie
8
+ initializer 'rails_otel_context.configure' do
9
+ RailsOtelContext.apply_env_configuration!
10
+ end
11
+
12
+ initializer 'rails_otel_context.install_adapters' do
13
+ ActiveSupport.on_load(:active_record) do
14
+ RailsOtelContext::Adapters.install!(app_root: Rails.root, config: RailsOtelContext.configuration)
15
+ RailsOtelContext::ActiveRecordContext.install!
16
+ end
17
+ end
18
+
19
+ # Runs after config/initializers/ so the OTel SDK tracer_provider is already configured.
20
+ config.after_initialize do
21
+ otel_config = RailsOtelContext.configuration
22
+ needs_processor = otel_config.call_context_enabled ||
23
+ otel_config.custom_span_attributes ||
24
+ otel_config.request_context_enabled ||
25
+ otel_config.span_name_formatter
26
+
27
+ if needs_processor && OpenTelemetry.tracer_provider.respond_to?(:add_span_processor)
28
+ processor = RailsOtelContext::CallContextProcessor.new(app_root: Rails.root)
29
+ OpenTelemetry.tracer_provider.add_span_processor(processor)
30
+ end
31
+ end
32
+
33
+ # Install request context capture on ActionController when it loads.
34
+ # Uses around_action with ensure for leak-proof cleanup on exceptions.
35
+ initializer 'rails_otel_context.install_request_context' do
36
+ ActiveSupport.on_load(:action_controller) do
37
+ if RailsOtelContext.configuration.request_context_enabled
38
+ around_action do |_controller, block|
39
+ RailsOtelContext::RequestContext.set(
40
+ controller: self.class.name,
41
+ action: action_name
42
+ )
43
+ block.call
44
+ ensure
45
+ RailsOtelContext::RequestContext.clear!
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOtelContext
4
+ # Thread-local storage for request-scoped context that gets propagated
5
+ # to all spans within a request. Uses raw Thread.current for minimal overhead —
6
+ # no object allocation, no mutex, ~5ns per read/write.
7
+ #
8
+ # Lifecycle:
9
+ # 1. Railtie's around_action sets controller + action at request start
10
+ # 2. CallContextProcessor reads them on every child span's on_start
11
+ # 3. around_action's ensure block clears them when the request ends
12
+ #
13
+ # Thread safety: each Puma thread has its own slot — no sharing, no contention.
14
+ module RequestContext
15
+ CONTROLLER_KEY = :_rails_otel_ctx_controller
16
+ ACTION_KEY = :_rails_otel_ctx_action
17
+
18
+ class << self
19
+ def set(controller:, action:)
20
+ Thread.current[CONTROLLER_KEY] = controller
21
+ Thread.current[ACTION_KEY] = action
22
+ end
23
+
24
+ # Returns [controller, action] in a single Thread.current access.
25
+ def fetch
26
+ t = Thread.current
27
+ [t[CONTROLLER_KEY], t[ACTION_KEY]]
28
+ end
29
+
30
+ def controller
31
+ Thread.current[CONTROLLER_KEY]
32
+ end
33
+
34
+ def action
35
+ Thread.current[ACTION_KEY]
36
+ end
37
+
38
+ def clear!
39
+ Thread.current[CONTROLLER_KEY] = nil
40
+ Thread.current[ACTION_KEY] = nil
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOtelContext
4
+ # Shared helper for finding the first app-code source location in the call stack.
5
+ # Used by all DB adapters to attach source file/line to slow query spans.
6
+ module SourceLocation
7
+ def source_location_for_app
8
+ return unless Thread.respond_to?(:each_caller_location)
9
+
10
+ Thread.each_caller_location do |location|
11
+ path = location.absolute_path || location.path
12
+ next unless path&.start_with?(app_root)
13
+ next if path.include?('/gems/')
14
+
15
+ return [path.delete_prefix("#{app_root}/"), location.lineno]
16
+ end
17
+
18
+ nil
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOtelContext
4
+ VERSION = '0.5.0'
5
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rails-otel-context is a Rails-specific gem
4
+ # Skip Rails check in test environment to allow unit testing
5
+ unless defined?(Rails) || ENV['RAILS_OTEL_CONTEXT_TEST']
6
+ raise LoadError, 'rails-otel-context requires Rails. This gem is designed for Rails applications only.'
7
+ end
8
+
9
+ require 'rails_otel_context/version'
10
+ require 'rails_otel_context/configuration'
11
+ require 'rails_otel_context/source_location'
12
+ require 'rails_otel_context/activerecord_context'
13
+ require 'rails_otel_context/adapters'
14
+ require 'rails_otel_context/request_context'
15
+ require 'rails_otel_context/call_context_processor'
16
+ require 'rails_otel_context/railtie' if defined?(Rails::Railtie)
17
+
18
+ module RailsOtelContext
19
+ class << self
20
+ def configuration
21
+ @configuration ||= Configuration.new
22
+ end
23
+
24
+ def configure
25
+ yield(configuration)
26
+ end
27
+
28
+ def reset_configuration!
29
+ @configuration = Configuration.new
30
+ end
31
+
32
+ def apply_env_configuration!(config = configuration)
33
+ config.pg_slow_query_enabled =
34
+ bool_env('RAILS_OTEL_CONTEXT_PG_SLOW_QUERY_ENABLED', config.pg_slow_query_enabled)
35
+ config.pg_slow_query_threshold_ms =
36
+ float_env('RAILS_OTEL_CONTEXT_PG_SLOW_QUERY_MS',
37
+ float_env('OTEL_SLOW_QUERY_MS', config.pg_slow_query_threshold_ms))
38
+
39
+ config.mysql2_slow_query_enabled =
40
+ bool_env('RAILS_OTEL_CONTEXT_MYSQL2_SLOW_QUERY_ENABLED', config.mysql2_slow_query_enabled)
41
+ config.mysql2_slow_query_threshold_ms =
42
+ float_env('RAILS_OTEL_CONTEXT_MYSQL2_SLOW_QUERY_MS', config.mysql2_slow_query_threshold_ms)
43
+
44
+ config.trilogy_slow_query_enabled =
45
+ bool_env('RAILS_OTEL_CONTEXT_TRILOGY_SLOW_QUERY_ENABLED', config.trilogy_slow_query_enabled)
46
+ config.trilogy_slow_query_threshold_ms =
47
+ float_env('RAILS_OTEL_CONTEXT_TRILOGY_SLOW_QUERY_MS', config.trilogy_slow_query_threshold_ms)
48
+
49
+ config.redis_source_enabled =
50
+ bool_env('RAILS_OTEL_CONTEXT_REDIS_SOURCE_ENABLED', config.redis_source_enabled)
51
+ config.clickhouse_enabled =
52
+ bool_env('RAILS_OTEL_CONTEXT_CLICKHOUSE_ENABLED', config.clickhouse_enabled)
53
+ config.clickhouse_slow_query_threshold_ms =
54
+ float_env('RAILS_OTEL_CONTEXT_CLICKHOUSE_SLOW_QUERY_MS', config.clickhouse_slow_query_threshold_ms)
55
+
56
+ config.call_context_enabled =
57
+ bool_env('RAILS_OTEL_CONTEXT_CALL_CONTEXT_ENABLED', config.call_context_enabled)
58
+
59
+ config.custom_span_attributes_enabled =
60
+ bool_env('RAILS_OTEL_CONTEXT_CUSTOM_SPAN_ATTRIBUTES_ENABLED', config.custom_span_attributes_enabled)
61
+
62
+ config.request_context_enabled =
63
+ bool_env('RAILS_OTEL_CONTEXT_REQUEST_CONTEXT_ENABLED', config.request_context_enabled)
64
+
65
+ config
66
+ end
67
+
68
+ def float_env(key, default)
69
+ value = ENV.fetch(key, nil)
70
+ return default if value.nil? || value.strip.empty?
71
+
72
+ Float(value)
73
+ rescue ArgumentError, TypeError
74
+ default
75
+ end
76
+
77
+ def bool_env(key, default)
78
+ value = ENV.fetch(key, nil)
79
+ return default if value.nil? || value.strip.empty?
80
+
81
+ return true if %w[1 true yes on].include?(value.strip.downcase)
82
+ return false if %w[0 false no off].include?(value.strip.downcase)
83
+
84
+ default
85
+ end
86
+ end
87
+ end
metadata ADDED
@@ -0,0 +1,100 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails-otel-context
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.0
5
+ platform: ruby
6
+ authors:
7
+ - Last9
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: opentelemetry-api
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: railties
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: activerecord
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '7.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.0'
54
+ description: Rails-specific OpenTelemetry enhancements for source location tracking
55
+ and database instrumentation. Maintained by Last9.
56
+ email:
57
+ - engineering@last9.io
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - LICENSE
63
+ - README.md
64
+ - lib/rails_otel_context.rb
65
+ - lib/rails_otel_context/activerecord_context.rb
66
+ - lib/rails_otel_context/adapters.rb
67
+ - lib/rails_otel_context/adapters/clickhouse.rb
68
+ - lib/rails_otel_context/adapters/mysql2.rb
69
+ - lib/rails_otel_context/adapters/pg.rb
70
+ - lib/rails_otel_context/adapters/redis.rb
71
+ - lib/rails_otel_context/adapters/trilogy.rb
72
+ - lib/rails_otel_context/call_context_processor.rb
73
+ - lib/rails_otel_context/configuration.rb
74
+ - lib/rails_otel_context/railtie.rb
75
+ - lib/rails_otel_context/request_context.rb
76
+ - lib/rails_otel_context/source_location.rb
77
+ - lib/rails_otel_context/version.rb
78
+ homepage: https://github.com/last9/rails-otel-context
79
+ licenses:
80
+ - MIT
81
+ metadata:
82
+ rubygems_mfa_required: 'true'
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: 3.1.0
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubygems_version: 4.0.2
98
+ specification_version: 4
99
+ summary: Production helpers for OpenTelemetry Ruby instrumentations in Rails.
100
+ test_files: []