rails-profiler 0.38.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: 9b58bf97abcca532a115936ba089a63a668d8593dc7311b59d345ea3d5488a6e
4
- data.tar.gz: 72144d7c3f5d434c9b61f3b700acd05a783b725fa21addd6c633174e334ce5cc
3
+ metadata.gz: 312d0ee269f45025f4192fd8e7491d90835e4d2da0a9ddfdbda6429644615981
4
+ data.tar.gz: 4dd491cba56414e4ed0291ff492e644cd6264a27e6830da54f23caebfcb9d666
5
5
  SHA512:
6
- metadata.gz: 901671364cc31c093aa9a9e5d083ab1fea68f9a1576df7b74239a3b9e6cf7003fe0e8ba4de5e6597b61f93acbc77ab85c976459793620652aab4c808ec5e5f05
7
- data.tar.gz: e745e616088f23d67a9420ba5bf6ffc93eb2a0250280590417b6be451194a1d7bbc524641e206270f5d1240ffe3bb2f611d42ede6472309e3700251e8f7703b0
6
+ metadata.gz: 20de3f4b06ce2e7827a12fc8d5304428cc7865414b820177001957c155e4e18afa0bbd8fb0acef04d624ee609cc9473d1cf2eb456699b4bdbfdc241b8ff06ec2
7
+ data.tar.gz: beea47dda7f64599af19dd12c44531cdc9c9310c6069782754a2295ff2df5a11aedee1cc1f14c407b2be2f38c8d89b6aedc228405d74eddaf0ead39a08f704e3
data/CHANGELOG.md CHANGED
@@ -19,6 +19,24 @@ 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
+
30
+ ## [0.39.0] - 2026-10-19
31
+
32
+ <!-- stamped -->
33
+
34
+ ### Added
35
+
36
+ - **OpenTelemetry:** with `config.otel_enabled = true`, each profiled request is exported as a trace: a root span carrying the profile's own `trace_id` and `span_id`, and a child span for each SQL query, view or partial render, cache operation and outbound `Net::HTTP` call. An application that already initialised OpenTelemetry keeps its own tracer provider; otherwise the gem sends the spans itself over OTLP `http/protobuf` to `config.otel_otlp_endpoint`, with `config.otel_service_name` (the application's name by default).
37
+ - **OpenTelemetry:** the gem does not depend on OpenTelemetry. Add `opentelemetry-sdk` and `opentelemetry-exporter-otlp` to the application's `Gemfile` to use it; without them, `otel_enabled = true` writes one message to the log and exports nothing. With `otel_enabled` at `false` (the default) nothing of OpenTelemetry is loaded.
38
+ - **Collectors:** the SQL, view and cache events now record their `started_at` (epoch seconds), which the trace export uses to place their spans.
39
+
22
40
  ## [0.38.0] - 2026-10-19
23
41
 
24
42
  <!-- stamped -->
@@ -40,6 +40,7 @@ module Profiler
40
40
  @cache_reads << {
41
41
  key: payload[:key],
42
42
  hit: payload[:hit],
43
+ started_at: started.to_f,
43
44
  duration: duration
44
45
  }
45
46
  end
@@ -48,6 +49,7 @@ module Profiler
48
49
  duration = ((finished - started) * 1000).round(2)
49
50
  @cache_writes << {
50
51
  key: payload[:key],
52
+ started_at: started.to_f,
51
53
  duration: duration
52
54
  }
53
55
  end
@@ -56,6 +58,7 @@ module Profiler
56
58
  duration = ((finished - started) * 1000).round(2)
57
59
  @cache_deletes << {
58
60
  key: payload[:key],
61
+ started_at: started.to_f,
59
62
  duration: duration
60
63
  }
61
64
  end
@@ -63,6 +63,7 @@ module Profiler
63
63
  query = Models::SqlQuery.new(
64
64
  sql: payload[:sql],
65
65
  duration: duration,
66
+ started_at: started.to_f,
66
67
  binds: extract_binds(payload[:binds]),
67
68
  name: payload[:name],
68
69
  connection: payload[:connection],
@@ -41,6 +41,7 @@ module Profiler
41
41
  @views << {
42
42
  identifier: payload[:identifier],
43
43
  layout: payload[:layout],
44
+ started_at: started.to_f,
44
45
  duration: duration
45
46
  }
46
47
  end
@@ -50,6 +51,7 @@ module Profiler
50
51
  @intervals << [started, finished]
51
52
  @partials << {
52
53
  identifier: payload[:identifier],
54
+ started_at: started.to_f,
53
55
  duration: duration
54
56
  }
55
57
  end
@@ -45,6 +45,7 @@ module Profiler
45
45
  :cors_allowed_origins, :api_forgery_protection, :frame_ancestors,
46
46
  :track_ajax, :ajax_skip_paths,
47
47
  :track_http, :slow_http_threshold, :http_backtrace_depth,
48
+ :otel_enabled, :otel_service_name, :otel_otlp_endpoint,
48
49
  :track_jobs,
49
50
  :track_console,
50
51
  :apply_env_overrides_when_disabled, :restrict_storage_permissions,
@@ -105,6 +106,11 @@ module Profiler
105
106
  # The profiler's own calls (cluster, slave proxy) are never recorded, whatever this says.
106
107
  @http_skip_hosts = []
107
108
  @http_backtrace_depth = 40
109
+ # OpenTelemetry export of each profiled request as a trace. Off: nothing of OpenTelemetry is
110
+ # loaded or touched. The opentelemetry-* gems are the application's to add to its Gemfile.
111
+ @otel_enabled = false
112
+ @otel_service_name = nil
113
+ @otel_otlp_endpoint = nil
108
114
  @track_jobs = true
109
115
  @track_console = true
110
116
  @apply_env_overrides_when_disabled = false
@@ -294,6 +300,11 @@ module Profiler
294
300
  mcp_enabled && mcp_transport.to_s == "http" ? true : false
295
301
  end
296
302
 
303
+ # The service.name of the exported spans: otel_service_name, else the application's name.
304
+ def resolved_otel_service_name
305
+ otel_service_name.to_s.strip.empty? ? resolved_name : otel_service_name.to_s
306
+ end
307
+
297
308
  def resolved_name
298
309
  @name || (defined?(Rails) ? Rails.application.class.module_parent_name.underscore : "profiler")
299
310
  end
@@ -4,6 +4,8 @@ require "rack"
4
4
  require_relative "../models/profile"
5
5
  require_relative "../current_context"
6
6
  require_relative "../trace_context"
7
+ require_relative "../otel/trace_exporter"
8
+ require_relative "../otel/metrics_exporter"
7
9
  require_relative "../collectors/lifecycle"
8
10
  require_relative "toolbar_injector"
9
11
  require_relative "capturing_body"
@@ -128,7 +130,7 @@ module Profiler
128
130
 
129
131
  # A profile that was not saved (a store unavailable, a save that failed) gets no token and
130
132
  # no toolbar: both would point to a profile that is not there.
131
- return [status, headers, body] unless Profiler.save_profile(profile, from: "ProfilerMiddleware")
133
+ return [status, headers, body] unless save_and_export(profile)
132
134
 
133
135
  set_header(headers, TOKEN_HEADER, profile.token)
134
136
 
@@ -172,7 +174,7 @@ module Profiler
172
174
  end
173
175
  record_response(env, profile, request_body, status, profiled_headers, "", nil, false)
174
176
  # Saved before the headers leave: a profile that is not there gets no token.
175
- set_header(headers, TOKEN_HEADER, profile.token) if Profiler.save_profile(profile, from: "ProfilerMiddleware")
177
+ set_header(headers, TOKEN_HEADER, profile.token) if save_and_export(profile)
176
178
  return [[status, headers, body], nil]
177
179
  end
178
180
 
@@ -217,11 +219,21 @@ module Profiler
217
219
  end
218
220
  end
219
221
 
220
- Profiler.save_profile(profile, from: "ProfilerMiddleware")
222
+ save_and_export(profile)
221
223
  rescue => e
222
224
  Profiler.log_error("ProfilerMiddleware: could not finish the profile of a streamed response", e)
223
225
  end
224
226
 
227
+ # The profile is saved, then sent to OpenTelemetry (spans, then metrics) when config.otel_enabled asks for it:
228
+ # once per profile, here, and not from Profiler.save_profile, which a profile reaches again
229
+ # whenever a late outbound call completes. The export never raises.
230
+ def save_and_export(profile)
231
+ saved = Profiler.save_profile(profile, from: "ProfilerMiddleware")
232
+ Otel::TraceExporter.export(profile)
233
+ Otel::MetricsExporter.export(profile)
234
+ saved
235
+ end
236
+
225
237
  def collect_from_any_thread?(collector)
226
238
  collector.respond_to?(:collect_from_any_thread?) && collector.collect_from_any_thread?
227
239
  end
@@ -266,7 +278,7 @@ module Profiler
266
278
  resp_content_type: ""
267
279
  )
268
280
  collect_all(profile, collectors)
269
- Profiler.save_profile(profile, from: "ProfilerMiddleware")
281
+ save_and_export(profile)
270
282
  rescue => e
271
283
  Profiler.log_error("ProfilerMiddleware: could not record a failed request", e)
272
284
  end
@@ -3,15 +3,16 @@
3
3
  module Profiler
4
4
  module Models
5
5
  class SqlQuery
6
- attr_reader :sql, :duration, :binds, :name, :connection, :backtrace
6
+ attr_reader :sql, :duration, :binds, :name, :connection, :backtrace, :started_at
7
7
 
8
- def initialize(sql:, duration:, binds: [], name: nil, connection: nil, backtrace: [])
8
+ def initialize(sql:, duration:, binds: [], name: nil, connection: nil, backtrace: [], started_at: nil)
9
9
  @sql = sql
10
10
  @duration = duration
11
11
  @binds = binds
12
12
  @name = name
13
13
  @connection = connection
14
14
  @backtrace = backtrace
15
+ @started_at = started_at
15
16
  end
16
17
 
17
18
  def slow?(threshold = 100)
@@ -29,6 +30,7 @@ module Profiler
29
30
  def to_h
30
31
  {
31
32
  sql: @sql,
33
+ started_at: @started_at,
32
34
  duration: @duration,
33
35
  binds: @binds,
34
36
  name: @name,
@@ -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
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ module Otel
5
+ # An id generator for an OpenTelemetry SDK tracer provider that hands out the trace-id and
6
+ # span-id the current thread asked for once (+with+), and otherwise defers to the generator
7
+ # the provider had. This is how a span gets the ids the profile already carries, through the
8
+ # SDK's public API only. Wraps nothing from OpenTelemetry at load time.
9
+ class ScopedIdGenerator
10
+ TRACE_KEY = :profiler_otel_trace_id
11
+ SPAN_KEY = :profiler_otel_span_id
12
+
13
+ def initialize(inner)
14
+ @inner = inner
15
+ end
16
+
17
+ attr_reader :inner
18
+
19
+ # Hex ids (32 and 16 characters) in, the byte strings the SDK uses out.
20
+ def self.with(trace_id:, span_id:)
21
+ Thread.current[TRACE_KEY] = [trace_id].pack("H*") if trace_id
22
+ Thread.current[SPAN_KEY] = [span_id].pack("H*") if span_id
23
+ yield
24
+ ensure
25
+ Thread.current[TRACE_KEY] = nil
26
+ Thread.current[SPAN_KEY] = nil
27
+ end
28
+
29
+ def generate_trace_id
30
+ take(TRACE_KEY) || @inner.generate_trace_id
31
+ end
32
+
33
+ def generate_span_id
34
+ take(SPAN_KEY) || @inner.generate_span_id
35
+ end
36
+
37
+ private
38
+
39
+ def take(key)
40
+ value = Thread.current[key]
41
+ Thread.current[key] = nil
42
+ value
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require_relative "scoped_id_generator"
5
+
6
+ module Profiler
7
+ module Otel
8
+ # Finds or builds the OpenTelemetry SDK the profiler exports through. The only place that
9
+ # requires "opentelemetry/..." and the only one that reaches the OpenTelemetry constants, and
10
+ # it does so only once config.otel_enabled is true: the gem declares no dependency on
11
+ # OpenTelemetry (like stackprof, it is optional), so an application that never heard of it
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.
17
+ module Sdk
18
+ LOCK = Monitor.new
19
+
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 = {}
39
+
40
+ class << self
41
+ # The tracer provider spans are started from, nil when export is off or cannot work. Warns
42
+ # once (Profiler.log_error) when it cannot; never raises.
43
+ def tracer_provider
44
+ provider_for(:traces) { setup(:traces, :host_tracer_provider, :build_own_tracer_provider) { |p| scoped(p) } }
45
+ end
46
+
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) }
51
+ end
52
+
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?
57
+
58
+ LOCK.synchronize do
59
+ return @available[signal] unless @available[signal].nil?
60
+
61
+ @available[signal] = load_gems(signal)
62
+ end
63
+ end
64
+
65
+ # The attributes every signal of this process carries.
66
+ def resource_attributes
67
+ { "service.name" => Profiler.configuration.resolved_otel_service_name }
68
+ end
69
+
70
+ # The endpoint the profiler's own exporter posts to: otel_otlp_endpoint, else the standard
71
+ # OTEL_EXPORTER_OTLP_* variables the exporter itself reads. nil when neither is set.
72
+ def otlp_endpoint(signal = :traces)
73
+ configured = Profiler.configuration.otel_otlp_endpoint.to_s.strip
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
78
+
79
+ SIGNALS.fetch(signal)[:endpoint_env].each do |name|
80
+ value = ENV[name].to_s.strip
81
+ return value unless value.empty?
82
+ end
83
+ nil
84
+ end
85
+
86
+ # Forgets everything decided so far (the specs, and a configuration change).
87
+ def reset!
88
+ LOCK.synchronize do
89
+ @state = {}
90
+ @available = {}
91
+ end
92
+ end
93
+
94
+ private
95
+
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 }
111
+ true
112
+ rescue LoadError => e
113
+ Profiler.log_error(
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."
117
+ )
118
+ false
119
+ end
120
+
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)
124
+
125
+ provider = send(host_finder) || send(builder)
126
+ provider = yield(provider) if provider && block_given?
127
+ { provider: provider }
128
+ end
129
+
130
+ # The SDK provider the application initialised for itself, if any. The API's global
131
+ # provider is a proxy until one is installed; its delegate is the SDK's.
132
+ def host_tracer_provider
133
+ provider = OpenTelemetry.tracer_provider
134
+ provider = provider.delegate if provider.respond_to?(:delegate)
135
+ provider if defined?(OpenTelemetry::SDK::Trace::TracerProvider) &&
136
+ provider.is_a?(OpenTelemetry::SDK::Trace::TracerProvider)
137
+ end
138
+
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)
160
+ end
161
+
162
+ # Our own provider, never installed as the global one: the application's OpenTelemetry
163
+ # stays as it is. OTLP over http/protobuf, the protocol of opentelemetry-exporter-otlp.
164
+ def build_own_tracer_provider
165
+ endpoint = endpoint_or_warn(:traces) or return nil
166
+
167
+ exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(endpoint: endpoint)
168
+ provider = OpenTelemetry::SDK::Trace::TracerProvider.new(resource: resource)
169
+ provider.add_span_processor(OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter))
170
+ at_exit { provider.shutdown }
171
+ provider
172
+ end
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
+
185
+ # The provider with its id generator wrapped, once.
186
+ def scoped(provider)
187
+ unless provider.respond_to?(:id_generator=)
188
+ Profiler.log_error("OpenTelemetry export disabled: the tracer provider cannot take an id generator")
189
+ return nil
190
+ end
191
+
192
+ unless provider.id_generator.is_a?(ScopedIdGenerator)
193
+ provider.id_generator = ScopedIdGenerator.new(provider.id_generator)
194
+ end
195
+ provider
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "uri"
5
+ require_relative "sdk"
6
+
7
+ module Profiler
8
+ module Otel
9
+ # Sends a finished request profile to OpenTelemetry as one trace: a root span for the request,
10
+ # a child span for each SQL query, view or partial rendering, cache operation and outbound
11
+ # HTTP call the collectors already recorded (nothing is instrumented twice). The root span
12
+ # carries the profile's own trace_id and span_id, so that the trace in the collector and the
13
+ # profile in the dashboard are the same thing; a traceparent received with the request makes
14
+ # the root a child of the caller's span, as the profile already says.
15
+ #
16
+ # Does nothing, and touches nothing of OpenTelemetry, unless config.otel_enabled is true.
17
+ # Never raises: an export that fails is logged and the request goes on.
18
+ module TraceExporter
19
+ SCOPE_NAME = "profiler"
20
+ SAMPLED_FLAG = 1
21
+
22
+ module_function
23
+
24
+ def export(profile)
25
+ return false unless Profiler.configuration.otel_enabled
26
+ return false unless profile.trace_id && profile.span_id
27
+
28
+ provider = Sdk.tracer_provider
29
+ return false unless provider
30
+
31
+ emit(provider.tracer(SCOPE_NAME, Profiler.version), profile)
32
+ true
33
+ rescue StandardError => e
34
+ Profiler.log_error_once(:otel_export, "OpenTelemetry export of a profile failed", e)
35
+ false
36
+ end
37
+
38
+ def emit(tracer, profile)
39
+ started = profile.started_at || Time.now
40
+ finished = profile.finished_at || started
41
+ root = start_root(tracer, profile, started)
42
+ parent = OpenTelemetry::Trace.context_with_span(root)
43
+
44
+ children(profile).each do |child|
45
+ start = child[:started_at] || started
46
+ span = tracer.start_span(child[:name], with_parent: parent, kind: child[:kind],
47
+ attributes: child[:attributes], start_timestamp: start)
48
+ mark_error(span, child[:error])
49
+ span.finish(end_timestamp: start + child[:duration].to_f / 1000.0)
50
+ end
51
+
52
+ root_status(root, profile)
53
+ root.finish(end_timestamp: finished)
54
+ end
55
+
56
+ def start_root(tracer, profile, started)
57
+ attributes = {
58
+ "http.request.method" => profile.method.to_s,
59
+ "url.path" => profile.path.to_s,
60
+ "profiler.token" => profile.token.to_s
61
+ }
62
+ attributes["http.response.status_code"] = profile.status if profile.status
63
+ name = "#{profile.method} #{profile.path}".strip
64
+
65
+ ScopedIdGenerator.with(trace_id: profile.trace_id, span_id: profile.span_id) do
66
+ tracer.start_span(name, with_parent: parent_context(profile), kind: :server,
67
+ attributes: attributes, start_timestamp: started)
68
+ end
69
+ end
70
+
71
+ # A caller's span (traceparent) when the request had one, otherwise an empty context, which
72
+ # makes the root span a true root. In both cases the trace_id comes from the generator.
73
+ def parent_context(profile)
74
+ return OpenTelemetry::Context.empty unless profile.parent_span_id
75
+
76
+ remote = OpenTelemetry::Trace::SpanContext.new(
77
+ trace_id: [profile.trace_id].pack("H*"), span_id: [profile.parent_span_id].pack("H*"),
78
+ trace_flags: OpenTelemetry::Trace::TraceFlags.from_byte(SAMPLED_FLAG), remote: true
79
+ )
80
+ OpenTelemetry::Trace.context_with_span(OpenTelemetry::Trace.non_recording_span(remote))
81
+ end
82
+
83
+ def root_status(root, profile)
84
+ return unless profile.status.to_i >= 500
85
+
86
+ root.status = OpenTelemetry::Trace::Status.error("HTTP #{profile.status}")
87
+ end
88
+
89
+ def mark_error(span, error)
90
+ span.status = OpenTelemetry::Trace::Status.error(error.to_s) if error
91
+ end
92
+
93
+ # Every collected event worth a span, as {name:, kind:, attributes:, started_at:, duration:}
94
+ # (duration in ms). An event without a start time (a profile saved before the collectors
95
+ # recorded it) starts with the request.
96
+ def children(profile)
97
+ data = profile.collectors_data || {}
98
+ sql_spans(fetch(data, "database")) + view_spans(fetch(data, "view")) +
99
+ cache_spans(fetch(data, "cache")) + http_spans(fetch(data, "http"))
100
+ end
101
+
102
+ def sql_spans(data)
103
+ list(data, :queries).filter_map do |query|
104
+ next if query[:transaction]
105
+
106
+ statement = query[:sql].to_s
107
+ { name: sql_name(statement), kind: :client, started_at: time(query[:started_at]),
108
+ duration: query[:duration],
109
+ attributes: { "db.query.text" => statement, "db.operation.name" => statement[/\A\s*(\w+)/, 1].to_s.upcase } }
110
+ end
111
+ end
112
+
113
+ def view_spans(data)
114
+ views = list(data, :views).map { |v| ["render_template", v] } +
115
+ list(data, :partials).map { |v| ["render_partial", v] }
116
+ views.map do |kind, view|
117
+ { name: "#{kind} #{view[:identifier]}", kind: :internal, started_at: time(view[:started_at]),
118
+ duration: view[:duration], attributes: { "code.filepath" => view[:identifier].to_s } }
119
+ end
120
+ end
121
+
122
+ # The key is left out: cache keys carry user data, and the dashboard is the place for them.
123
+ def cache_spans(data)
124
+ reads = list(data, :reads).map { |e| ["read", e, { "cache.hit" => e[:hit] ? true : false }] }
125
+ writes = list(data, :writes).map { |e| ["write", e, {}] }
126
+ deletes = list(data, :deletes).map { |e| ["delete", e, {}] }
127
+ (reads + writes + deletes).map do |operation, event, attributes|
128
+ { name: "cache #{operation}", kind: :internal, started_at: time(event[:started_at]),
129
+ duration: event[:duration], attributes: attributes.merge("cache.operation" => operation) }
130
+ end
131
+ end
132
+
133
+ # Scheme, host and path only: the query string of an outbound call may hold a token.
134
+ def http_spans(data)
135
+ list(data, :requests).filter_map do |request|
136
+ next if request[:in_flight]
137
+
138
+ url = safe_url(request[:url])
139
+ attributes = { "http.request.method" => request[:method].to_s, "url.full" => url }
140
+ attributes["http.response.status_code"] = request[:status] if request[:status].to_i.positive?
141
+ { name: "HTTP #{request[:method]}", kind: :client, started_at: time(request[:started_at]),
142
+ duration: request[:duration], attributes: attributes, error: request[:error] }
143
+ end
144
+ end
145
+
146
+ def sql_name(statement)
147
+ operation = statement[/\A\s*(\w+)/, 1].to_s.upcase
148
+ operation.empty? ? "sql" : operation
149
+ end
150
+
151
+ def safe_url(url)
152
+ uri = URI.parse(url.to_s)
153
+ uri.query = nil
154
+ uri.fragment = nil
155
+ uri.userinfo = nil
156
+ uri.to_s
157
+ rescue URI::InvalidURIError
158
+ ""
159
+ end
160
+
161
+ def fetch(data, key)
162
+ data[key] || data[key.to_sym] || {}
163
+ end
164
+
165
+ # A collector's list, whatever the keys' type (symbols as collected, strings once stored).
166
+ def list(data, key)
167
+ Array(data[key] || data[key.to_s]).map { |entry| entry.transform_keys(&:to_sym) }
168
+ end
169
+
170
+ def time(value)
171
+ case value
172
+ when Numeric then Time.at(value)
173
+ when String then Time.iso8601(value)
174
+ end
175
+ rescue ArgumentError
176
+ nil
177
+ end
178
+ end
179
+ end
180
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.38.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.38.0
4
+ version: 0.40.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
@@ -235,6 +235,10 @@ 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
239
+ - lib/profiler/otel/scoped_id_generator.rb
240
+ - lib/profiler/otel/sdk.rb
241
+ - lib/profiler/otel/trace_exporter.rb
238
242
  - lib/profiler/process_snapshot.rb
239
243
  - lib/profiler/railtie.rb
240
244
  - lib/profiler/redaction.rb