rails-profiler 0.38.0 → 0.39.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: 6bc10ed77cb2126254ad1b94fb697766bd7e8305fdeb957e7a5dd44909e622dd
4
+ data.tar.gz: d250a8a1b41ea1668b6835cbf07a0371422fa90b3c820be182da4c6734e82dd1
5
5
  SHA512:
6
- metadata.gz: 901671364cc31c093aa9a9e5d083ab1fea68f9a1576df7b74239a3b9e6cf7003fe0e8ba4de5e6597b61f93acbc77ab85c976459793620652aab4c808ec5e5f05
7
- data.tar.gz: e745e616088f23d67a9420ba5bf6ffc93eb2a0250280590417b6be451194a1d7bbc524641e206270f5d1240ffe3bb2f611d42ede6472309e3700251e8f7703b0
6
+ metadata.gz: 9c5c4774740ee3f12f3411cba23c3ff0c9c2f28db248d50a1d84f800a5a66d59bff11016f761d4fd1639105a854217c7190ebd5753febfaac08a3c80b1228eb2
7
+ data.tar.gz: 41d9f3e676848d74bd27c02062d0fe593f12c50aa2ef7de62f7bc1913e6a6ba8816858fade838969eaf1aacb1ca83a13a8f722215a0d4c72b2cdddeed76c6fa9
data/CHANGELOG.md CHANGED
@@ -19,6 +19,16 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.39.0] - 2026-10-19
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Added
27
+
28
+ - **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).
29
+ - **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.
30
+ - **Collectors:** the SQL, view and cache events now record their `started_at` (epoch seconds), which the trace export uses to place their spans.
31
+
22
32
  ## [0.38.0] - 2026-10-19
23
33
 
24
34
  <!-- 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,7 @@ 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"
7
8
  require_relative "../collectors/lifecycle"
8
9
  require_relative "toolbar_injector"
9
10
  require_relative "capturing_body"
@@ -128,7 +129,7 @@ module Profiler
128
129
 
129
130
  # A profile that was not saved (a store unavailable, a save that failed) gets no token and
130
131
  # no toolbar: both would point to a profile that is not there.
131
- return [status, headers, body] unless Profiler.save_profile(profile, from: "ProfilerMiddleware")
132
+ return [status, headers, body] unless save_and_export(profile)
132
133
 
133
134
  set_header(headers, TOKEN_HEADER, profile.token)
134
135
 
@@ -172,7 +173,7 @@ module Profiler
172
173
  end
173
174
  record_response(env, profile, request_body, status, profiled_headers, "", nil, false)
174
175
  # 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")
176
+ set_header(headers, TOKEN_HEADER, profile.token) if save_and_export(profile)
176
177
  return [[status, headers, body], nil]
177
178
  end
178
179
 
@@ -217,11 +218,20 @@ module Profiler
217
218
  end
218
219
  end
219
220
 
220
- Profiler.save_profile(profile, from: "ProfilerMiddleware")
221
+ save_and_export(profile)
221
222
  rescue => e
222
223
  Profiler.log_error("ProfilerMiddleware: could not finish the profile of a streamed response", e)
223
224
  end
224
225
 
226
+ # The profile is saved, then sent to OpenTelemetry when config.otel_enabled asks for it:
227
+ # once per profile, here, and not from Profiler.save_profile, which a profile reaches again
228
+ # whenever a late outbound call completes. The export never raises.
229
+ def save_and_export(profile)
230
+ saved = Profiler.save_profile(profile, from: "ProfilerMiddleware")
231
+ Otel::TraceExporter.export(profile)
232
+ saved
233
+ end
234
+
225
235
  def collect_from_any_thread?(collector)
226
236
  collector.respond_to?(:collect_from_any_thread?) && collector.collect_from_any_thread?
227
237
  end
@@ -266,7 +276,7 @@ module Profiler
266
276
  resp_content_type: ""
267
277
  )
268
278
  collect_all(profile, collectors)
269
- Profiler.save_profile(profile, from: "ProfilerMiddleware")
279
+ save_and_export(profile)
270
280
  rescue => e
271
281
  Profiler.log_error("ProfilerMiddleware: could not record a failed request", e)
272
282
  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,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,146 @@
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. Written so that metrics can share it: +available?+,
13
+ # +resource+ and the OTLP settings are not specific to traces.
14
+ module Sdk
15
+ 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
+ @state = nil
20
+
21
+ class << self
22
+ # The tracer provider spans are started from, nil when export is off or cannot work. Warns
23
+ # once (Profiler.log_error) when it cannot; never raises.
24
+ def tracer_provider
25
+ return nil unless Profiler.configuration.otel_enabled
26
+
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
33
+ end
34
+
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?
39
+
40
+ LOCK.synchronize do
41
+ return @available unless @available.nil?
42
+
43
+ @available = load_gems
44
+ end
45
+ end
46
+
47
+ # The attributes every signal of this process carries.
48
+ def resource_attributes
49
+ { "service.name" => Profiler.configuration.resolved_otel_service_name }
50
+ end
51
+
52
+ # The endpoint the profiler's own exporter posts to: otel_otlp_endpoint, else the standard
53
+ # OTEL_EXPORTER_OTLP_* variables the exporter itself reads. nil when neither is set.
54
+ def otlp_endpoint
55
+ configured = Profiler.configuration.otel_otlp_endpoint.to_s.strip
56
+ return configured unless configured.empty?
57
+
58
+ ENDPOINT_ENV.each do |name|
59
+ value = ENV[name].to_s.strip
60
+ return value unless value.empty?
61
+ end
62
+ nil
63
+ end
64
+
65
+ # Forgets everything decided so far (the specs, and a configuration change).
66
+ def reset!
67
+ LOCK.synchronize do
68
+ @state = nil
69
+ @available = nil
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def load_gems
76
+ REQUIRES.each { |name| require name }
77
+ true
78
+ rescue LoadError => e
79
+ 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."
83
+ )
84
+ false
85
+ end
86
+
87
+ def setup_tracer_provider
88
+ return { provider: nil } unless available?
89
+
90
+ existing = host_tracer_provider
91
+ return { provider: scoped(existing) } if existing
92
+
93
+ own = build_own_tracer_provider
94
+ { provider: own && scoped(own) }
95
+ end
96
+
97
+ # The SDK provider the application initialised for itself, if any. The API's global
98
+ # provider is a proxy until one is installed; its delegate is the SDK's.
99
+ def host_tracer_provider
100
+ provider = OpenTelemetry.tracer_provider
101
+ provider = provider.delegate if provider.respond_to?(:delegate)
102
+ sdk_provider?(provider) ? provider : nil
103
+ end
104
+
105
+ def sdk_provider?(provider)
106
+ defined?(OpenTelemetry::SDK::Trace::TracerProvider) &&
107
+ provider.is_a?(OpenTelemetry::SDK::Trace::TracerProvider)
108
+ end
109
+
110
+ # Our own provider, never installed as the global one: the application's OpenTelemetry
111
+ # stays as it is. OTLP over http/protobuf, the protocol of opentelemetry-exporter-otlp.
112
+ 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
121
+
122
+ 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
+ )
126
+ provider.add_span_processor(OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter))
127
+ at_exit { provider.shutdown }
128
+ provider
129
+ end
130
+
131
+ # The provider with its id generator wrapped, once.
132
+ def scoped(provider)
133
+ unless provider.respond_to?(:id_generator=)
134
+ Profiler.log_error("OpenTelemetry export disabled: the tracer provider cannot take an id generator")
135
+ return nil
136
+ end
137
+
138
+ unless provider.id_generator.is_a?(ScopedIdGenerator)
139
+ provider.id_generator = ScopedIdGenerator.new(provider.id_generator)
140
+ end
141
+ provider
142
+ end
143
+ end
144
+ end
145
+ end
146
+ 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.39.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.39.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
@@ -235,6 +235,9 @@ 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/scoped_id_generator.rb
239
+ - lib/profiler/otel/sdk.rb
240
+ - lib/profiler/otel/trace_exporter.rb
238
241
  - lib/profiler/process_snapshot.rb
239
242
  - lib/profiler/railtie.rb
240
243
  - lib/profiler/redaction.rb