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 +4 -4
- data/CHANGELOG.md +10 -0
- data/lib/profiler/collectors/cache_collector.rb +3 -0
- data/lib/profiler/collectors/database_collector.rb +1 -0
- data/lib/profiler/collectors/view_collector.rb +2 -0
- data/lib/profiler/configuration.rb +11 -0
- data/lib/profiler/middleware/profiler_middleware.rb +14 -4
- data/lib/profiler/models/sql_query.rb +4 -2
- data/lib/profiler/otel/scoped_id_generator.rb +46 -0
- data/lib/profiler/otel/sdk.rb +146 -0
- data/lib/profiler/otel/trace_exporter.rb +180 -0
- data/lib/profiler/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6bc10ed77cb2126254ad1b94fb697766bd7e8305fdeb957e7a5dd44909e622dd
|
|
4
|
+
data.tar.gz: d250a8a1b41ea1668b6835cbf07a0371422fa90b3c820be182da4c6734e82dd1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/profiler/version.rb
CHANGED
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.
|
|
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
|