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 +4 -4
- data/CHANGELOG.md +18 -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 +16 -4
- data/lib/profiler/models/sql_query.rb +4 -2
- data/lib/profiler/otel/metrics_exporter.rb +135 -0
- data/lib/profiler/otel/scoped_id_generator.rb +46 -0
- data/lib/profiler/otel/sdk.rb +200 -0
- data/lib/profiler/otel/trace_exporter.rb +180 -0
- data/lib/profiler/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 312d0ee269f45025f4192fd8e7491d90835e4d2da0a9ddfdbda6429644615981
|
|
4
|
+
data.tar.gz: 4dd491cba56414e4ed0291ff492e644cd6264a27e6830da54f23caebfcb9d666
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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.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
|