rails-otel-context 0.9.4 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d2e72559008d91dce5419a16fd46e12cf3e9f40f1d5767458e2c4f1a4bf15b2c
4
- data.tar.gz: 86e841a066551ed02d85c15f0af9276c394de50586d70d07bc1cb588b2e33de4
3
+ metadata.gz: f8ea9eb65d7e405bbd14a39889bbad983edbba5c4bdcce252157d426a2372bc6
4
+ data.tar.gz: c70ddac87d64c8359fe82fe142bda62e4968b3429ea6944796b475270310d1d7
5
5
  SHA512:
6
- metadata.gz: 7326aaca0ef8d0db842513355e10002deedc16cafa5823c39a8dfaf983a05e794482cea8cf371fcac2b42c7f52861e1c1b104ae97f0c3a2ffcbbc0107d8eb6e4
7
- data.tar.gz: b4621bcc0dedd6d721268ed1f738f482335ab7953b45d8957655e62b046a5da1322cb76f40e1acbab20e2605dad17b06178f210067ce4dd3c3f1905b533f69b7
6
+ metadata.gz: cfee7357103d1336be233b9b6724c1b6678117f7fcd3e57dda75ca473a3a608c8f511e7151db29cd9bce34627fcd8d0b482fcdb640812941c960385bf57acadd
7
+ data.tar.gz: 15888432fb759223a87cdf35f0dd9ae00c07ac59ef038b06bd49644968996b23fc910b0cca0f0912857bf8a4101ad9d005aa66596cef5b4a1e29de68a12486c8
data/README.md CHANGED
@@ -118,6 +118,37 @@ RailsOtelContext.configure do |c|
118
118
  end
119
119
  ```
120
120
 
121
+ ## Conditional loading (`require: false`)
122
+
123
+ If your Gemfile has `require: false` and you load the gem from an initializer, call `RailsOtelContext.install!` explicitly. Loading the gem inside `config/initializers/` is too late for Rails to run the railtie's initializer hooks, so without an explicit `install!` call the AR subscriber and `around_action` hooks are never registered — `code.activerecord.*` and `rails.controller` will be absent from all spans.
124
+
125
+ ```ruby
126
+ # Gemfile
127
+ gem 'rails-otel-context', '~> 0.9', require: false
128
+
129
+ # config/initializers/opentelemetry.rb
130
+ return unless ENV['ENABLE_OTLP']
131
+
132
+ require 'rails_otel_context'
133
+
134
+ RailsOtelContext.configure do |c|
135
+ c.span_name_formatter = lambda { |original, ar| ... }
136
+ end
137
+
138
+ RailsOtelContext.install! # registers AR hooks, around_action, and the span processor
139
+
140
+ require 'opentelemetry/sdk'
141
+ require 'opentelemetry/exporter/otlp'
142
+ require 'opentelemetry/instrumentation/all'
143
+
144
+ OpenTelemetry::SDK.configure do |c|
145
+ c.service_name = ENV.fetch('OTEL_SERVICE_NAME', 'my_app')
146
+ c.use_all
147
+ end
148
+ ```
149
+
150
+ `install!` is idempotent — the railtie calls it automatically via `after_initialize`, so apps that let Bundler auto-require the gem do not need to call it.
151
+
121
152
  ## How `code.namespace` / `code.function` works
122
153
 
123
154
  On every span start, the gem walks the Ruby call stack (`Thread.each_caller_location`) and finds the first frame inside `Rails.root`. That frame becomes the four `code.*` attributes.
@@ -32,12 +32,11 @@ module RailsOtelContext
32
32
  end
33
33
  end
34
34
 
35
- # AR context and span renaming handled by CallContextProcessor.apply_db_context.
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
- result = super(*args)
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
- # AR context and span renaming handled by CallContextProcessor.apply_db_context.
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
- # Capture before super: PG yields into a block so the original
49
- # call stack is only visible here, not inside the result block.
50
- site = mod.call_site_for_app
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
- # AR context and span renaming handled by CallContextProcessor.apply_db_context.
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
- result = super(sql)
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
 
@@ -71,26 +71,29 @@ module RailsOtelContext
71
71
  nil
72
72
  end
73
73
 
74
- def force_flush(timeout: nil); end
75
-
76
- def shutdown(timeout: nil); end
74
+ # Return Export::SUCCESS (0) so the SDK's tracer_provider.force_flush/shutdown
75
+ # can safely call results.max across all registered processors without raising
76
+ # ArgumentError when mixing our return value with integer status codes.
77
+ def force_flush(**) = 0
78
+ def shutdown(**) = 0
77
79
 
78
80
  private
79
81
 
80
82
  def apply_call_context(span)
81
83
  # Explicit override: app code called FrameContext.with_frame (or Frameable).
82
84
  # O(1) — no stack walk. Takes priority over automatic detection.
83
- pushed = FrameContext.current
84
- if pushed
85
- span.set_attribute('code.namespace', pushed[:class_name])
86
- span.set_attribute('code.function', pushed[:method_name]) if pushed[:method_name]
87
- return
88
- end
85
+ # DB adapters push filepath/lineno too so the child span gets full call-site info.
86
+ site = FrameContext.current
87
+ unless site
88
+ # Default: walk the call stack to find the nearest app-code frame.
89
+ return unless Thread.respond_to?(:each_caller_location)
89
90
 
90
- # Default: walk the call stack to find the nearest app-code frame.
91
- return unless Thread.respond_to?(:each_caller_location)
91
+ site = call_site_for_app
92
+ end
93
+ set_call_site_attributes(span, site)
94
+ end
92
95
 
93
- site = call_site_for_app
96
+ def set_call_site_attributes(span, site)
94
97
  return unless site
95
98
 
96
99
  span.set_attribute('code.namespace', site[:class_name])
@@ -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
- def with_frame(class_name:, method_name:)
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] = { class_name: class_name, method_name: method_name }.freeze
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] = { class_name: class_name, method_name: method_name }.freeze
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
 
@@ -5,23 +5,12 @@ require 'rails_otel_context/call_context_processor'
5
5
 
6
6
  module RailsOtelContext
7
7
  class Railtie < Rails::Railtie
8
- initializer 'rails_otel_context.install_adapters' do
9
- ActiveSupport.on_load(:active_record) do
10
- RailsOtelContext::Adapters.install!(app_root: Rails.root, config: RailsOtelContext.configuration)
11
- RailsOtelContext::ActiveRecordContext.install!(app_root: Rails.root)
12
- end
13
- end
14
-
15
- # Runs after config/initializers/ so the OTel SDK tracer_provider is already configured.
8
+ # Runs after config/initializers/ so the OTel SDK tracer_provider is already
9
+ # configured. install! is idempotent — if the app already called
10
+ # RailsOtelContext.install! from an initializer this is a no-op for hooks,
11
+ # but install_processor! still runs (it self-guards with @processor_installed).
16
12
  config.after_initialize do
17
- RailsOtelContext.install_processor!
18
-
19
- # Warm the table→model map once at boot (after eager_load! in production so
20
- # all descendants are available). Without this, the first SQL-named span on a
21
- # cold boot hits an empty map and falls through without model context.
22
- ActiveSupport.on_load(:active_record) do
23
- RailsOtelContext::ActiveRecordContext.ar_table_model_map
24
- end
13
+ RailsOtelContext.install!
25
14
  end
26
15
 
27
16
  # Reset the table→model map after every code reload in development.
@@ -31,43 +20,5 @@ module RailsOtelContext
31
20
  config.to_prepare do
32
21
  RailsOtelContext::ActiveRecordContext.reset_ar_table_model_map!
33
22
  end
34
-
35
- # Capture controller + action for every request and propagate them to all
36
- # child spans via RequestContext. Also resets the N+1 query counter at both
37
- # the start and end of every request to prevent bleed across Puma thread reuse.
38
- # Always-on — no config gate.
39
- #
40
- # Both hooks are required: ActionController::Base fires :action_controller,
41
- # ActionController::API (Rails API-only apps) fires :action_controller_api.
42
- # In Rails 8 API-only apps :action_controller never fires, so without the
43
- # second hook rails.controller / rails.action would be absent from every span.
44
- initializer 'rails_otel_context.install_request_context' do
45
- around_action_hook = proc do
46
- around_action do |_controller, block|
47
- RailsOtelContext::RequestContext.set(
48
- controller: self.class.name,
49
- action: action_name
50
- )
51
- block.call
52
- ensure
53
- RailsOtelContext::RequestContext.clear!
54
- end
55
- end
56
- ActiveSupport.on_load(:action_controller, &around_action_hook)
57
- ActiveSupport.on_load(:action_controller_api, &around_action_hook)
58
- end
59
-
60
- # Capture job class name for every ActiveJob execution and propagate it to all
61
- # child spans via RequestContext so rails.job appears on every span in the job.
62
- initializer 'rails_otel_context.install_job_context' do
63
- ActiveSupport.on_load(:active_job) do
64
- around_perform do |_job, block|
65
- RailsOtelContext::RequestContext.set_job(job_class: self.class.name)
66
- block.call
67
- ensure
68
- RailsOtelContext::RequestContext.clear_job!
69
- end
70
- end
71
- end
72
23
  end
73
24
  end
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsOtelContext
4
- VERSION = '0.9.4'
4
+ VERSION = '0.9.7'
5
5
  end
@@ -30,13 +30,32 @@ module RailsOtelContext
30
30
  @configuration = Configuration.new
31
31
  end
32
32
 
33
+ # Full installation: registers all Rails hooks (AR adapters, around_action,
34
+ # around_perform) and the CallContextProcessor. Safe to call from a
35
+ # config/initializers file when the gem is loaded with require: false:
36
+ #
37
+ # # config/initializers/opentelemetry.rb
38
+ # return unless ENV['ENABLE_OTLP']
39
+ # require 'rails_otel_context'
40
+ # RailsOtelContext.configure { |c| ... }
41
+ # RailsOtelContext.install!
42
+ #
43
+ # The Railtie calls this automatically via after_initialize, so apps that
44
+ # let Bundler auto-require the gem do not need to call it explicitly.
45
+ # Safe to call multiple times — idempotent.
46
+ def install!(app_root: nil)
47
+ app_root ||= Rails.root if defined?(Rails)
48
+ register_hooks!(app_root) unless @hooks_installed
49
+ install_processor!
50
+ end
51
+
33
52
  # Registers CallContextProcessor with the OTel tracer_provider.
34
- # Called automatically by the Railtie after_initialize. Call this manually
35
- # when OpenTelemetry::SDK.configure runs after Rails boot (e.g. in a custom
36
- # after_initialize block):
53
+ # Called automatically by install!. Call this manually only when the OTel
54
+ # SDK is configured after install! has already run (rare):
37
55
  #
38
- # OpenTelemetry::SDK.configure { |c| c.use_all() }
39
- # RailsOtelContext.install_processor!
56
+ # RailsOtelContext.install! # hooks up AR/request context
57
+ # OpenTelemetry::SDK.configure { … } # SDK configured later
58
+ # RailsOtelContext.install_processor! # add processor to the now-real provider
40
59
  #
41
60
  # Safe to call multiple times — idempotent.
42
61
  def install_processor!
@@ -49,6 +68,43 @@ module RailsOtelContext
49
68
  OpenTelemetry.tracer_provider.add_span_processor(processor)
50
69
  end
51
70
 
71
+ private
72
+
73
+ def register_hooks!(app_root)
74
+ @hooks_installed = true
75
+
76
+ ActiveSupport.on_load(:active_record) do
77
+ RailsOtelContext::Adapters.install!(app_root: app_root, config: RailsOtelContext.configuration)
78
+ RailsOtelContext::ActiveRecordContext.install!(app_root: app_root)
79
+ RailsOtelContext::ActiveRecordContext.ar_table_model_map
80
+ end
81
+
82
+ around_action_hook = proc do
83
+ around_action do |_controller, block|
84
+ RailsOtelContext::RequestContext.set(
85
+ controller: self.class.name,
86
+ action: action_name
87
+ )
88
+ block.call
89
+ ensure
90
+ RailsOtelContext::RequestContext.clear!
91
+ end
92
+ end
93
+ ActiveSupport.on_load(:action_controller, &around_action_hook)
94
+ ActiveSupport.on_load(:action_controller_api, &around_action_hook)
95
+
96
+ ActiveSupport.on_load(:active_job) do
97
+ around_perform do |_job, block|
98
+ RailsOtelContext::RequestContext.set_job(job_class: self.class.name)
99
+ block.call
100
+ ensure
101
+ RailsOtelContext::RequestContext.clear_job!
102
+ end
103
+ end
104
+ end
105
+
106
+ public
107
+
52
108
  # Convenience delegates to FrameContext — see FrameContext for full docs.
53
109
  def with_frame(class_name:, method_name:, &block)
54
110
  FrameContext.with_frame(class_name: class_name, method_name: method_name, &block)
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
4
+ version: 0.9.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Last9