rails-profiler 0.39.0 → 0.40.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6bc10ed77cb2126254ad1b94fb697766bd7e8305fdeb957e7a5dd44909e622dd
4
- data.tar.gz: d250a8a1b41ea1668b6835cbf07a0371422fa90b3c820be182da4c6734e82dd1
3
+ metadata.gz: 312d0ee269f45025f4192fd8e7491d90835e4d2da0a9ddfdbda6429644615981
4
+ data.tar.gz: 4dd491cba56414e4ed0291ff492e644cd6264a27e6830da54f23caebfcb9d666
5
5
  SHA512:
6
- metadata.gz: 9c5c4774740ee3f12f3411cba23c3ff0c9c2f28db248d50a1d84f800a5a66d59bff11016f761d4fd1639105a854217c7190ebd5753febfaac08a3c80b1228eb2
7
- data.tar.gz: 41d9f3e676848d74bd27c02062d0fe593f12c50aa2ef7de62f7bc1913e6a6ba8816858fade838969eaf1aacb1ca83a13a8f722215a0d4c72b2cdddeed76c6fa9
6
+ metadata.gz: 20de3f4b06ce2e7827a12fc8d5304428cc7865414b820177001957c155e4e18afa0bbd8fb0acef04d624ee609cc9473d1cf2eb456699b4bdbfdc241b8ff06ec2
7
+ data.tar.gz: beea47dda7f64599af19dd12c44531cdc9c9310c6069782754a2295ff2df5a11aedee1cc1f14c407b2be2f38c8d89b6aedc228405d74eddaf0ead39a08f704e3
data/CHANGELOG.md CHANGED
@@ -19,6 +19,14 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.40.0] - 2026-10-19
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Added
27
+
28
+ - **OpenTelemetry:** with the same `config.otel_enabled = true`, each profiled request also records metrics: duration histograms (request, SQL query, view, cache operation, outbound `Net::HTTP` call) and counters for allocated objects and for requests that ran an N+1. Attributes are low-cardinality on purpose: `controller#action`, HTTP method and status, SQL verb and cache operation, never a token, URL or SQL text. **Experimental:** it needs `opentelemetry-metrics-api`, `opentelemetry-metrics-sdk` and `opentelemetry-exporter-otlp-metrics` in the application's `Gemfile`, pinned to an exact version because the metrics SDK is alpha; without them one message is logged and traces are unaffected.
29
+
22
30
  ## [0.39.0] - 2026-10-19
23
31
 
24
32
  <!-- stamped -->
@@ -5,6 +5,7 @@ require_relative "../models/profile"
5
5
  require_relative "../current_context"
6
6
  require_relative "../trace_context"
7
7
  require_relative "../otel/trace_exporter"
8
+ require_relative "../otel/metrics_exporter"
8
9
  require_relative "../collectors/lifecycle"
9
10
  require_relative "toolbar_injector"
10
11
  require_relative "capturing_body"
@@ -223,12 +224,13 @@ module Profiler
223
224
  Profiler.log_error("ProfilerMiddleware: could not finish the profile of a streamed response", e)
224
225
  end
225
226
 
226
- # The profile is saved, then sent to OpenTelemetry when config.otel_enabled asks for it:
227
+ # The profile is saved, then sent to OpenTelemetry (spans, then metrics) when config.otel_enabled asks for it:
227
228
  # once per profile, here, and not from Profiler.save_profile, which a profile reaches again
228
229
  # whenever a late outbound call completes. The export never raises.
229
230
  def save_and_export(profile)
230
231
  saved = Profiler.save_profile(profile, from: "ProfilerMiddleware")
231
232
  Otel::TraceExporter.export(profile)
233
+ Otel::MetricsExporter.export(profile)
232
234
  saved
233
235
  end
234
236
 
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sdk"
4
+ require_relative "trace_exporter"
5
+ require_relative "../mcp/resources/n1_patterns"
6
+
7
+ module Profiler
8
+ module Otel
9
+ # Records a finished request profile in OpenTelemetry metrics: duration histograms (request,
10
+ # SQL query, view or partial, cache operation, outbound HTTP call; milliseconds, the unit the
11
+ # profiler measures in), a counter of the objects the request allocated, and a counter of the
12
+ # requests that ran an N+1. The events are the ones TraceExporter turns into spans, here
13
+ # aggregated.
14
+ #
15
+ # Attributes are kept to a bounded set of values, on purpose (unlike spans, a metric is one
16
+ # time series per attribute combination): controller#action (or "unknown"), the HTTP method
17
+ # (a standard verb, else "_OTHER"), the status code (request duration only), the SQL verb
18
+ # (SELECT, INSERT, UPDATE, DELETE, else "OTHER") and the cache operation. Never a profile
19
+ # token, a path or URL, SQL text, a cache key or a host.
20
+ #
21
+ # The metrics SDK is alpha: its API moves between releases (see docs/configuration.md).
22
+ # Does nothing, and touches nothing of OpenTelemetry, unless config.otel_enabled is true.
23
+ # Never raises: a failure is logged and the request goes on.
24
+ module MetricsExporter
25
+ SCOPE_NAME = "profiler"
26
+ UNKNOWN = "unknown"
27
+ HTTP_METHODS = %w[GET HEAD POST PUT PATCH DELETE OPTIONS CONNECT TRACE].freeze
28
+ SQL_VERBS = %w[SELECT INSERT UPDATE DELETE].freeze
29
+
30
+ module_function
31
+
32
+ def export(profile)
33
+ return false unless Profiler.configuration.otel_enabled
34
+
35
+ provider = Sdk.meter_provider
36
+ return false unless provider
37
+
38
+ record(instruments(provider), profile)
39
+ true
40
+ rescue StandardError => e
41
+ Profiler.log_error_once(:otel_metrics_export, "OpenTelemetry metrics export of a profile failed", e)
42
+ false
43
+ end
44
+
45
+ # The instruments of a provider (the meter caches them by name, so asking again is cheap).
46
+ def instruments(provider)
47
+ meter = provider.meter(SCOPE_NAME, version: Profiler.version)
48
+ {
49
+ request: meter.create_histogram("profiler.request.duration", unit: "ms",
50
+ description: "Duration of a profiled HTTP request"),
51
+ sql: meter.create_histogram("profiler.db.query.duration", unit: "ms",
52
+ description: "Duration of a SQL query"),
53
+ view: meter.create_histogram("profiler.view.render.duration", unit: "ms",
54
+ description: "Duration of a view or partial render"),
55
+ cache: meter.create_histogram("profiler.cache.operation.duration", unit: "ms",
56
+ description: "Duration of a cache operation"),
57
+ http: meter.create_histogram("profiler.http.client.request.duration", unit: "ms",
58
+ description: "Duration of an outbound Net::HTTP call"),
59
+ allocations: meter.create_counter("profiler.request.allocations", unit: "{object}",
60
+ description: "Objects allocated by profiled requests"),
61
+ n_plus_one: meter.create_counter("profiler.request.n_plus_one", unit: "{request}",
62
+ description: "Profiled requests that ran an N+1 query")
63
+ }
64
+ end
65
+
66
+ def record(instruments, profile)
67
+ data = profile.collectors_data || {}
68
+ base = { "profiler.controller_action" => controller_action(data), "http.request.method" => method_name(profile.method) }
69
+
70
+ request_attributes = base.dup
71
+ request_attributes["http.response.status_code"] = profile.status.to_i if (100..599).cover?(profile.status.to_i)
72
+ instruments[:request].record(profile.duration.to_f, attributes: request_attributes) if profile.duration
73
+
74
+ queries = queries(data)
75
+ queries.each do |query|
76
+ instruments[:sql].record(query[:duration].to_f, attributes: base.merge("db.operation.name" => sql_verb(query[:sql])))
77
+ end
78
+ record_all(instruments[:view], base, views(data))
79
+ record_cache(instruments[:cache], base, data)
80
+ record_all(instruments[:http], base, TraceExporter.list(TraceExporter.fetch(data, "http"), :requests).reject { |r| r[:in_flight] })
81
+
82
+ allocations = profile.allocated_objects
83
+ instruments[:allocations].add(allocations, attributes: base) if allocations.is_a?(Integer) && allocations.positive?
84
+ instruments[:n_plus_one].add(1, attributes: base) if n_plus_one?(queries)
85
+ end
86
+
87
+ def record_all(histogram, attributes, events)
88
+ events.each { |event| histogram.record(event[:duration].to_f, attributes: attributes) if event[:duration] }
89
+ end
90
+
91
+ def record_cache(histogram, base, data)
92
+ cache = TraceExporter.fetch(data, "cache")
93
+ %i[reads writes deletes].each do |kind|
94
+ operation = kind.to_s.chomp("s")
95
+ TraceExporter.list(cache, kind).each do |event|
96
+ next unless event[:duration]
97
+
98
+ histogram.record(event[:duration].to_f, attributes: base.merge("cache.operation" => operation))
99
+ end
100
+ end
101
+ end
102
+
103
+ def views(data)
104
+ view = TraceExporter.fetch(data, "view")
105
+ TraceExporter.list(view, :views) + TraceExporter.list(view, :partials)
106
+ end
107
+
108
+ # The SQL queries of the request, transaction statements apart (the Database tab does too).
109
+ def queries(data)
110
+ TraceExporter.list(TraceExporter.fetch(data, "database"), :queries).reject { |q| q[:transaction] || q[:duration].nil? }
111
+ end
112
+
113
+ # An N+1 here: some query, once its constants are normalized (N1Patterns.normalize_sql, the
114
+ # one the n1-patterns resource uses), ran more than once within this one request.
115
+ def n_plus_one?(queries)
116
+ queries.group_by { |q| MCP::Resources::N1Patterns.normalize_sql(q[:sql].to_s) }.any? { |_, group| group.size > 1 }
117
+ end
118
+
119
+ def controller_action(data)
120
+ value = TraceExporter.fetch(data, "request")[:controller_action] || TraceExporter.fetch(data, "request")["controller_action"]
121
+ value.to_s.empty? ? UNKNOWN : value.to_s
122
+ end
123
+
124
+ def method_name(method)
125
+ name = method.to_s.upcase
126
+ HTTP_METHODS.include?(name) ? name : "_OTHER"
127
+ end
128
+
129
+ def sql_verb(sql)
130
+ verb = sql.to_s[/\A\s*(\w+)/, 1].to_s.upcase
131
+ SQL_VERBS.include?(verb) ? verb : "OTHER"
132
+ end
133
+ end
134
+ end
135
+ end
@@ -9,38 +9,56 @@ module Profiler
9
9
  # requires "opentelemetry/..." and the only one that reaches the OpenTelemetry constants, and
10
10
  # it does so only once config.otel_enabled is true: the gem declares no dependency on
11
11
  # OpenTelemetry (like stackprof, it is optional), so an application that never heard of it
12
- # loads and profiles exactly as before. Written so that metrics can share it: +available?+,
13
- # +resource+ and the OTLP settings are not specific to traces.
12
+ # loads and profiles exactly as before. Traces and metrics share it: the detection of the
13
+ # application's own provider, the resource and the OTLP settings are the same, only the gems
14
+ # and the provider class differ (see SIGNALS). Each signal is decided on its own, so the
15
+ # trace gems without the metrics ones (or the reverse) gives one signal and a warning for the
16
+ # other.
14
17
  module Sdk
15
18
  LOCK = Monitor.new
16
- REQUIRES = ["opentelemetry/sdk", "opentelemetry/exporter/otlp"].freeze
17
- ENDPOINT_ENV = %w[OTEL_EXPORTER_OTLP_TRACES_ENDPOINT OTEL_EXPORTER_OTLP_ENDPOINT].freeze
18
19
 
19
- @state = nil
20
+ # What differs between the signals: the gems to load, the API's global provider accessor,
21
+ # the SDK class of a provider, and the standard endpoint variables.
22
+ SIGNALS = {
23
+ traces: {
24
+ requires: ["opentelemetry/sdk", "opentelemetry/exporter/otlp"],
25
+ gems: "opentelemetry-sdk and opentelemetry-exporter-otlp",
26
+ endpoint_env: %w[OTEL_EXPORTER_OTLP_TRACES_ENDPOINT OTEL_EXPORTER_OTLP_ENDPOINT].freeze
27
+ },
28
+ metrics: {
29
+ requires: ["opentelemetry/sdk", "opentelemetry-metrics-sdk", "opentelemetry/exporter/otlp_metrics"],
30
+ gems: "opentelemetry-metrics-api, opentelemetry-metrics-sdk and opentelemetry-exporter-otlp-metrics " \
31
+ "(alpha, pin an exact version of each)",
32
+ endpoint_env: %w[OTEL_EXPORTER_OTLP_METRICS_ENDPOINT OTEL_EXPORTER_OTLP_ENDPOINT].freeze
33
+ }
34
+ }.freeze
35
+ ENDPOINT_ENV = SIGNALS.dig(:traces, :endpoint_env)
36
+
37
+ @state = {}
38
+ @available = {}
20
39
 
21
40
  class << self
22
41
  # The tracer provider spans are started from, nil when export is off or cannot work. Warns
23
42
  # once (Profiler.log_error) when it cannot; never raises.
24
43
  def tracer_provider
25
- return nil unless Profiler.configuration.otel_enabled
44
+ provider_for(:traces) { setup(:traces, :host_tracer_provider, :build_own_tracer_provider) { |p| scoped(p) } }
45
+ end
26
46
 
27
- state = @state || LOCK.synchronize { @state ||= setup_tracer_provider }
28
- state[:provider]
29
- rescue StandardError, LoadError => e
30
- LOCK.synchronize { @state = { provider: nil } }
31
- Profiler.log_error("OpenTelemetry export disabled: setup failed", e)
32
- nil
47
+ # The meter provider metrics are recorded with, nil when export is off or cannot work. The
48
+ # application's SDK MeterProvider when it has one, else our own, never installed globally.
49
+ def meter_provider
50
+ provider_for(:metrics) { setup(:metrics, :host_meter_provider, :build_own_meter_provider) }
33
51
  end
34
52
 
35
- # Whether the SDK and the OTLP exporter can be loaded. Loads them (the sole place that
36
- # does), and says so in the log once if they cannot.
37
- def available?
38
- return @available unless @available.nil?
53
+ # Whether the SDK and the OTLP exporter of a signal can be loaded. Loads them (the sole
54
+ # place that does), and says so in the log once if they cannot.
55
+ def available?(signal = :traces)
56
+ return @available[signal] unless @available[signal].nil?
39
57
 
40
58
  LOCK.synchronize do
41
- return @available unless @available.nil?
59
+ return @available[signal] unless @available[signal].nil?
42
60
 
43
- @available = load_gems
61
+ @available[signal] = load_gems(signal)
44
62
  end
45
63
  end
46
64
 
@@ -51,11 +69,14 @@ module Profiler
51
69
 
52
70
  # The endpoint the profiler's own exporter posts to: otel_otlp_endpoint, else the standard
53
71
  # OTEL_EXPORTER_OTLP_* variables the exporter itself reads. nil when neither is set.
54
- def otlp_endpoint
72
+ def otlp_endpoint(signal = :traces)
55
73
  configured = Profiler.configuration.otel_otlp_endpoint.to_s.strip
56
- return configured unless configured.empty?
74
+ unless configured.empty?
75
+ # One setting serves both signals: the traces path means the metrics one.
76
+ return signal == :metrics ? configured.sub(%r{/v1/traces/?\z}, "/v1/metrics") : configured
77
+ end
57
78
 
58
- ENDPOINT_ENV.each do |name|
79
+ SIGNALS.fetch(signal)[:endpoint_env].each do |name|
59
80
  value = ENV[name].to_s.strip
60
81
  return value unless value.empty?
61
82
  end
@@ -65,33 +86,45 @@ module Profiler
65
86
  # Forgets everything decided so far (the specs, and a configuration change).
66
87
  def reset!
67
88
  LOCK.synchronize do
68
- @state = nil
69
- @available = nil
89
+ @state = {}
90
+ @available = {}
70
91
  end
71
92
  end
72
93
 
73
94
  private
74
95
 
75
- def load_gems
76
- REQUIRES.each { |name| require name }
96
+ # The provider of a signal: nil when export is off, decided once (in the block), nil with
97
+ # one logged error when the setup raises.
98
+ def provider_for(signal)
99
+ return nil unless Profiler.configuration.otel_enabled
100
+
101
+ state = @state[signal] || LOCK.synchronize { @state[signal] ||= yield }
102
+ state[:provider]
103
+ rescue StandardError, LoadError => e
104
+ LOCK.synchronize { @state[signal] = { provider: nil } }
105
+ Profiler.log_error("OpenTelemetry #{signal} export disabled: setup failed", e)
106
+ nil
107
+ end
108
+
109
+ def load_gems(signal)
110
+ SIGNALS.fetch(signal)[:requires].each { |name| require name }
77
111
  true
78
112
  rescue LoadError => e
79
113
  Profiler.log_error(
80
- "config.otel_enabled is true but the OpenTelemetry gems are not available (#{e.message}); " \
81
- "add opentelemetry-sdk and opentelemetry-exporter-otlp to the application's Gemfile. " \
82
- "OpenTelemetry export is off."
114
+ "config.otel_enabled is true but the OpenTelemetry #{signal} gems are not available " \
115
+ "(#{e.message}); add #{SIGNALS.fetch(signal)[:gems]} to the application's Gemfile. " \
116
+ "OpenTelemetry #{signal} export is off."
83
117
  )
84
118
  false
85
119
  end
86
120
 
87
- def setup_tracer_provider
88
- return { provider: nil } unless available?
89
-
90
- existing = host_tracer_provider
91
- return { provider: scoped(existing) } if existing
121
+ # The host's provider when it has one (then prepared by the block, if any), else our own.
122
+ def setup(signal, host_finder, builder)
123
+ return { provider: nil } unless available?(signal)
92
124
 
93
- own = build_own_tracer_provider
94
- { provider: own && scoped(own) }
125
+ provider = send(host_finder) || send(builder)
126
+ provider = yield(provider) if provider && block_given?
127
+ { provider: provider }
95
128
  end
96
129
 
97
130
  # The SDK provider the application initialised for itself, if any. The API's global
@@ -99,35 +132,56 @@ module Profiler
99
132
  def host_tracer_provider
100
133
  provider = OpenTelemetry.tracer_provider
101
134
  provider = provider.delegate if provider.respond_to?(:delegate)
102
- sdk_provider?(provider) ? provider : nil
135
+ provider if defined?(OpenTelemetry::SDK::Trace::TracerProvider) &&
136
+ provider.is_a?(OpenTelemetry::SDK::Trace::TracerProvider)
103
137
  end
104
138
 
105
- def sdk_provider?(provider)
106
- defined?(OpenTelemetry::SDK::Trace::TracerProvider) &&
107
- provider.is_a?(OpenTelemetry::SDK::Trace::TracerProvider)
139
+ def host_meter_provider
140
+ provider = OpenTelemetry.meter_provider
141
+ provider = provider.delegate if provider.respond_to?(:delegate)
142
+ provider if defined?(OpenTelemetry::SDK::Metrics::MeterProvider) &&
143
+ provider.is_a?(OpenTelemetry::SDK::Metrics::MeterProvider)
144
+ end
145
+
146
+ # The endpoint of our own exporter, nil (after one logged error) when there is none.
147
+ def endpoint_or_warn(signal)
148
+ endpoint = otlp_endpoint(signal)
149
+ return endpoint if endpoint
150
+
151
+ Profiler.log_error(
152
+ "config.otel_enabled is true but there is no OpenTelemetry SDK in the application and " \
153
+ "config.otel_otlp_endpoint is not set: OpenTelemetry #{signal} export is off."
154
+ )
155
+ nil
156
+ end
157
+
158
+ def resource
159
+ OpenTelemetry::SDK::Resources::Resource.create(resource_attributes)
108
160
  end
109
161
 
110
162
  # Our own provider, never installed as the global one: the application's OpenTelemetry
111
163
  # stays as it is. OTLP over http/protobuf, the protocol of opentelemetry-exporter-otlp.
112
164
  def build_own_tracer_provider
113
- endpoint = otlp_endpoint
114
- unless endpoint
115
- Profiler.log_error(
116
- "config.otel_enabled is true but there is no OpenTelemetry SDK in the application and " \
117
- "config.otel_otlp_endpoint is not set: OpenTelemetry export is off."
118
- )
119
- return nil
120
- end
165
+ endpoint = endpoint_or_warn(:traces) or return nil
121
166
 
122
167
  exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(endpoint: endpoint)
123
- provider = OpenTelemetry::SDK::Trace::TracerProvider.new(
124
- resource: OpenTelemetry::SDK::Resources::Resource.create(resource_attributes)
125
- )
168
+ provider = OpenTelemetry::SDK::Trace::TracerProvider.new(resource: resource)
126
169
  provider.add_span_processor(OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter))
127
170
  at_exit { provider.shutdown }
128
171
  provider
129
172
  end
130
173
 
174
+ # Same, for metrics: a periodic reader pushes what was recorded to the OTLP metrics exporter.
175
+ def build_own_meter_provider
176
+ endpoint = endpoint_or_warn(:metrics) or return nil
177
+
178
+ exporter = OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new(endpoint: endpoint)
179
+ provider = OpenTelemetry::SDK::Metrics::MeterProvider.new(resource: resource)
180
+ provider.add_metric_reader(OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new(exporter: exporter))
181
+ at_exit { provider.shutdown }
182
+ provider
183
+ end
184
+
131
185
  # The provider with its id generator wrapped, once.
132
186
  def scoped(provider)
133
187
  unless provider.respond_to?(:id_generator=)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.39.0"
4
+ VERSION = "0.40.0"
5
5
 
6
6
  GEM_ROOT = File.expand_path("../..", __dir__)
7
7
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.39.0
4
+ version: 0.40.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
@@ -235,6 +235,7 @@ files:
235
235
  - lib/profiler/models/profile.rb
236
236
  - lib/profiler/models/sql_query.rb
237
237
  - lib/profiler/models/timeline_event.rb
238
+ - lib/profiler/otel/metrics_exporter.rb
238
239
  - lib/profiler/otel/scoped_id_generator.rb
239
240
  - lib/profiler/otel/sdk.rb
240
241
  - lib/profiler/otel/trace_exporter.rb