flare 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +148 -0
- data/app/controllers/flare/application_controller.rb +22 -0
- data/app/controllers/flare/jobs_controller.rb +55 -0
- data/app/controllers/flare/requests_controller.rb +73 -0
- data/app/controllers/flare/spans_controller.rb +101 -0
- data/app/helpers/flare/application_helper.rb +168 -0
- data/app/views/flare/jobs/index.html.erb +69 -0
- data/app/views/flare/jobs/show.html.erb +323 -0
- data/app/views/flare/requests/index.html.erb +120 -0
- data/app/views/flare/requests/show.html.erb +498 -0
- data/app/views/flare/spans/index.html.erb +112 -0
- data/app/views/flare/spans/show.html.erb +184 -0
- data/app/views/layouts/flare/application.html.erb +126 -0
- data/config/routes.rb +20 -0
- data/exe/flare +9 -0
- data/lib/flare/backoff_policy.rb +73 -0
- data/lib/flare/cli/doctor_command.rb +129 -0
- data/lib/flare/cli/output.rb +45 -0
- data/lib/flare/cli/setup_command.rb +404 -0
- data/lib/flare/cli/status_command.rb +47 -0
- data/lib/flare/cli.rb +50 -0
- data/lib/flare/configuration.rb +121 -0
- data/lib/flare/engine.rb +43 -0
- data/lib/flare/http_metrics_config.rb +101 -0
- data/lib/flare/metric_counter.rb +45 -0
- data/lib/flare/metric_flusher.rb +124 -0
- data/lib/flare/metric_key.rb +42 -0
- data/lib/flare/metric_span_processor.rb +470 -0
- data/lib/flare/metric_storage.rb +42 -0
- data/lib/flare/metric_submitter.rb +221 -0
- data/lib/flare/source_location.rb +113 -0
- data/lib/flare/sqlite_exporter.rb +279 -0
- data/lib/flare/storage/sqlite.rb +789 -0
- data/lib/flare/storage.rb +54 -0
- data/lib/flare/version.rb +5 -0
- data/lib/flare.rb +411 -0
- data/public/flare-assets/flare.css +1245 -0
- data/public/flare-assets/images/flipper.png +0 -0
- metadata +240 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "metric_key"
|
|
4
|
+
require_relative "http_metrics_config"
|
|
5
|
+
|
|
6
|
+
module Flare
|
|
7
|
+
TRANSACTION_NAME_ATTRIBUTE = "flare.transaction_name" unless const_defined?(:TRANSACTION_NAME_ATTRIBUTE)
|
|
8
|
+
|
|
9
|
+
# OpenTelemetry SpanProcessor that extracts metrics from spans.
|
|
10
|
+
# Aggregates counts, durations, and error rates by namespace/service/target/operation.
|
|
11
|
+
class MetricSpanProcessor
|
|
12
|
+
# Standard OTel span kind symbols
|
|
13
|
+
SERVER = :server
|
|
14
|
+
CLIENT = :client
|
|
15
|
+
CONSUMER = :consumer
|
|
16
|
+
|
|
17
|
+
# Cache store class name patterns mapped to short service names
|
|
18
|
+
CACHE_STORE_MAP = {
|
|
19
|
+
"redis" => "redis",
|
|
20
|
+
"mem_cache" => "memcache",
|
|
21
|
+
"memcache" => "memcache",
|
|
22
|
+
"dalli" => "memcache",
|
|
23
|
+
"file" => "file",
|
|
24
|
+
"memory" => "memory",
|
|
25
|
+
"null" => "null",
|
|
26
|
+
"solid_cache" => "solid_cache"
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
def initialize(storage:, http_metrics_config: nil)
|
|
30
|
+
@storage = storage
|
|
31
|
+
@http_metrics_config = http_metrics_config || HttpMetricsConfig::DEFAULT
|
|
32
|
+
@pid = $$
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Called when a span starts - no-op for metrics
|
|
36
|
+
def on_start(span, parent_context); end
|
|
37
|
+
|
|
38
|
+
# Called when a span ends - extract and record metrics.
|
|
39
|
+
# OTel SDK 1.10+ calls on_finish; older versions call on_end.
|
|
40
|
+
def on_finish(span)
|
|
41
|
+
return unless span.end_timestamp && span.start_timestamp
|
|
42
|
+
|
|
43
|
+
detect_forking
|
|
44
|
+
|
|
45
|
+
if web_request?(span)
|
|
46
|
+
record_web_metric(span)
|
|
47
|
+
elsif background_job?(span)
|
|
48
|
+
record_background_metric(span)
|
|
49
|
+
elsif database_span?(span)
|
|
50
|
+
record_db_metric(span)
|
|
51
|
+
elsif http_client_span?(span)
|
|
52
|
+
record_http_metric(span)
|
|
53
|
+
elsif cache_span?(span)
|
|
54
|
+
record_cache_metric(span)
|
|
55
|
+
elsif view_span?(span)
|
|
56
|
+
record_view_metric(span)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def force_flush(timeout: nil)
|
|
61
|
+
OpenTelemetry::SDK::Trace::Export::SUCCESS
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
alias_method :on_end, :on_finish
|
|
65
|
+
|
|
66
|
+
def shutdown(timeout: nil)
|
|
67
|
+
OpenTelemetry::SDK::Trace::Export::SUCCESS
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Detect forking (Puma workers, Passenger, etc.) and restart the
|
|
73
|
+
# metrics flusher whose timer thread died during fork.
|
|
74
|
+
# Called on every span end — the same pattern Flipper uses in record().
|
|
75
|
+
def detect_forking
|
|
76
|
+
if @pid != $$
|
|
77
|
+
Flare.log "Fork detected (was=#{@pid} now=#{$$}), restarting metrics flusher"
|
|
78
|
+
@pid = $$
|
|
79
|
+
Flare.after_fork
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Web requests: any server span (entry point for this service).
|
|
84
|
+
# Server spans may have a remote parent from distributed trace propagation
|
|
85
|
+
# (e.g., traceparent header), but they still represent the web request
|
|
86
|
+
# handled by this application.
|
|
87
|
+
def web_request?(span)
|
|
88
|
+
span.kind == SERVER
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Background jobs: root consumer spans (ActiveJob, Sidekiq)
|
|
92
|
+
def background_job?(span)
|
|
93
|
+
span.kind == CONSUMER && root_span?(span)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Database spans: any span with db.system attribute
|
|
97
|
+
def database_span?(span)
|
|
98
|
+
span.attributes["db.system"]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# HTTP client spans: client spans with http.method or http.request.method
|
|
102
|
+
def http_client_span?(span)
|
|
103
|
+
span.kind == CLIENT &&
|
|
104
|
+
(span.attributes["http.method"] || span.attributes["http.request.method"])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Cache spans: ActiveSupport cache notification spans
|
|
108
|
+
def cache_span?(span)
|
|
109
|
+
name = span.name.to_s
|
|
110
|
+
name.start_with?("cache_") && name.end_with?(".active_support")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# View spans: ActionView render notification spans
|
|
114
|
+
def view_span?(span)
|
|
115
|
+
name = span.name.to_s
|
|
116
|
+
name.start_with?("render_") && name.end_with?(".action_view")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def root_span?(span)
|
|
120
|
+
span.parent_span_id.nil? ||
|
|
121
|
+
span.parent_span_id == OpenTelemetry::Trace::INVALID_SPAN_ID
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def record_web_metric(span)
|
|
125
|
+
target = span.attributes[Flare::TRANSACTION_NAME_ATTRIBUTE]
|
|
126
|
+
|
|
127
|
+
unless target
|
|
128
|
+
# Skip requests that never hit a Rails controller (assets, favicon, bot probes, etc.)
|
|
129
|
+
# These have no code.namespace set by ActionPack instrumentation.
|
|
130
|
+
return unless span.attributes["code.namespace"]
|
|
131
|
+
|
|
132
|
+
controller = span.attributes["code.namespace"]
|
|
133
|
+
action = span.attributes["code.function"]
|
|
134
|
+
target = action ? "#{controller}##{action}" : controller
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
key = MetricKey.new(
|
|
138
|
+
bucket: bucket_time(span),
|
|
139
|
+
namespace: "web",
|
|
140
|
+
service: "rails",
|
|
141
|
+
target: target,
|
|
142
|
+
operation: http_status_class(span)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
@storage.increment(
|
|
146
|
+
key,
|
|
147
|
+
duration_ms: duration_ms(span),
|
|
148
|
+
error: http_error?(span)
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def record_background_metric(span)
|
|
153
|
+
transaction_name = span.attributes[Flare::TRANSACTION_NAME_ATTRIBUTE]
|
|
154
|
+
|
|
155
|
+
key = MetricKey.new(
|
|
156
|
+
bucket: bucket_time(span),
|
|
157
|
+
namespace: "job",
|
|
158
|
+
service: extract_job_system(span),
|
|
159
|
+
target: transaction_name || span.attributes["code.namespace"] || span.attributes["messaging.destination"] || "unknown",
|
|
160
|
+
operation: transaction_name ? "perform" : (span.attributes["code.function"] || span.name)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@storage.increment(
|
|
164
|
+
key,
|
|
165
|
+
duration_ms: duration_ms(span),
|
|
166
|
+
error: span_error?(span)
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def record_db_metric(span)
|
|
171
|
+
db_system = span.attributes["db.system"].to_s
|
|
172
|
+
|
|
173
|
+
key = MetricKey.new(
|
|
174
|
+
bucket: bucket_time(span),
|
|
175
|
+
namespace: "db",
|
|
176
|
+
service: db_system,
|
|
177
|
+
target: extract_db_target(span, db_system),
|
|
178
|
+
operation: extract_db_operation(span, db_system)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
@storage.increment(
|
|
182
|
+
key,
|
|
183
|
+
duration_ms: duration_ms(span),
|
|
184
|
+
error: span_error?(span)
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def record_http_metric(span)
|
|
189
|
+
host = span.attributes["http.host"] ||
|
|
190
|
+
span.attributes["server.address"] ||
|
|
191
|
+
span.attributes["net.peer.name"] ||
|
|
192
|
+
extract_host_from_url(span) ||
|
|
193
|
+
"unknown"
|
|
194
|
+
|
|
195
|
+
method = span.attributes["http.method"] ||
|
|
196
|
+
span.attributes["http.request.method"] ||
|
|
197
|
+
"UNKNOWN"
|
|
198
|
+
|
|
199
|
+
path = resolve_http_path(span, host.to_s.downcase)
|
|
200
|
+
|
|
201
|
+
key = MetricKey.new(
|
|
202
|
+
bucket: bucket_time(span),
|
|
203
|
+
namespace: "http",
|
|
204
|
+
service: host.to_s.downcase,
|
|
205
|
+
target: "#{method.to_s.upcase} #{path}",
|
|
206
|
+
operation: http_status_class(span)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
@storage.increment(
|
|
210
|
+
key,
|
|
211
|
+
duration_ms: duration_ms(span),
|
|
212
|
+
error: http_error?(span)
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def record_cache_metric(span)
|
|
217
|
+
operation = extract_cache_operation(span)
|
|
218
|
+
|
|
219
|
+
key = MetricKey.new(
|
|
220
|
+
bucket: bucket_time(span),
|
|
221
|
+
namespace: "cache",
|
|
222
|
+
service: extract_cache_store(span),
|
|
223
|
+
target: operation,
|
|
224
|
+
operation: operation
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
@storage.increment(
|
|
228
|
+
key,
|
|
229
|
+
duration_ms: duration_ms(span),
|
|
230
|
+
error: span_error?(span)
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def record_view_metric(span)
|
|
235
|
+
key = MetricKey.new(
|
|
236
|
+
bucket: bucket_time(span),
|
|
237
|
+
namespace: "view",
|
|
238
|
+
service: "actionview",
|
|
239
|
+
target: extract_view_template(span),
|
|
240
|
+
operation: extract_view_operation(span)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
@storage.increment(
|
|
244
|
+
key,
|
|
245
|
+
duration_ms: duration_ms(span),
|
|
246
|
+
error: span_error?(span)
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def bucket_time(span)
|
|
251
|
+
# Use span start time, truncated to minute (in UTC for consistent bucketing)
|
|
252
|
+
time = Time.at(span.start_timestamp / 1_000_000_000.0).utc
|
|
253
|
+
Time.utc(time.year, time.month, time.day, time.hour, time.min, 0)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def duration_ms(span)
|
|
257
|
+
((span.end_timestamp - span.start_timestamp) / 1_000_000.0).round
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def http_error?(span)
|
|
261
|
+
status = span.attributes["http.status_code"] || span.attributes["http.response.status_code"]
|
|
262
|
+
status.to_i >= 500
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def http_status_class(span)
|
|
266
|
+
status = span.attributes["http.status_code"] || span.attributes["http.response.status_code"]
|
|
267
|
+
code = status.to_i
|
|
268
|
+
if code >= 500 then "5xx"
|
|
269
|
+
elsif code >= 400 then "4xx"
|
|
270
|
+
elsif code >= 300 then "3xx"
|
|
271
|
+
elsif code >= 200 then "2xx"
|
|
272
|
+
elsif code >= 100 then "1xx"
|
|
273
|
+
else "unknown"
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def span_error?(span)
|
|
278
|
+
span.status&.code == OpenTelemetry::Trace::Status::ERROR
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def extract_job_system(span)
|
|
282
|
+
# Detect job system from span attributes
|
|
283
|
+
if span.attributes["messaging.system"]
|
|
284
|
+
span.attributes["messaging.system"].to_s
|
|
285
|
+
elsif span.name.to_s.include?("sidekiq")
|
|
286
|
+
"sidekiq"
|
|
287
|
+
elsif span.name.to_s.include?("ActiveJob")
|
|
288
|
+
"activejob"
|
|
289
|
+
else
|
|
290
|
+
"background"
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def extract_db_target(span, db_system)
|
|
295
|
+
case db_system
|
|
296
|
+
when "redis"
|
|
297
|
+
# For Redis, use db.redis.namespace if available, or the database index
|
|
298
|
+
span.attributes["db.redis.database_index"]&.to_s ||
|
|
299
|
+
span.attributes["db.name"] ||
|
|
300
|
+
"default"
|
|
301
|
+
else
|
|
302
|
+
# For SQL databases, prefer table name from attributes or parsed from SQL
|
|
303
|
+
span.attributes["db.sql.table"] ||
|
|
304
|
+
extract_sql_table(span) ||
|
|
305
|
+
span.attributes["db.name"] ||
|
|
306
|
+
"unknown"
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def extract_db_operation(span, db_system)
|
|
311
|
+
case db_system
|
|
312
|
+
when "redis"
|
|
313
|
+
span.attributes["db.operation"]&.to_s&.downcase ||
|
|
314
|
+
span.name.to_s.split.last&.downcase ||
|
|
315
|
+
"command"
|
|
316
|
+
else
|
|
317
|
+
span.attributes["db.operation"]&.to_s&.upcase ||
|
|
318
|
+
extract_sql_operation(span) ||
|
|
319
|
+
"query"
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def extract_sql_operation(span)
|
|
324
|
+
statement = span.attributes["db.statement"]
|
|
325
|
+
return nil unless statement
|
|
326
|
+
|
|
327
|
+
# Extract first word (SELECT, INSERT, UPDATE, DELETE, etc.)
|
|
328
|
+
statement.to_s.strip.split(/\s+/).first&.upcase
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def extract_sql_table(span)
|
|
332
|
+
statement = span.attributes["db.statement"]
|
|
333
|
+
return nil unless statement
|
|
334
|
+
|
|
335
|
+
sql = statement.to_s.strip
|
|
336
|
+
|
|
337
|
+
case sql
|
|
338
|
+
when /\bFROM\s+[`"]?(\w+)[`"]?/i
|
|
339
|
+
$1
|
|
340
|
+
when /\bINTO\s+[`"]?(\w+)[`"]?/i
|
|
341
|
+
$1
|
|
342
|
+
when /\bUPDATE\s+[`"]?(\w+)[`"]?/i
|
|
343
|
+
$1
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def extract_host_from_url(span)
|
|
348
|
+
url = span.attributes["http.url"] || span.attributes["url.full"]
|
|
349
|
+
return nil unless url
|
|
350
|
+
|
|
351
|
+
URI.parse(url.to_s).host
|
|
352
|
+
rescue URI::InvalidURIError
|
|
353
|
+
nil
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Resolve the HTTP path using the http_metrics_config.
|
|
357
|
+
# Returns the resolved path string (could be "*", a custom string, or normalized path).
|
|
358
|
+
def resolve_http_path(span, host)
|
|
359
|
+
raw_path = raw_http_path(span)
|
|
360
|
+
result = @http_metrics_config.resolve(host, raw_path)
|
|
361
|
+
|
|
362
|
+
if result == "*"
|
|
363
|
+
"*"
|
|
364
|
+
elsif result.is_a?(String)
|
|
365
|
+
result
|
|
366
|
+
else
|
|
367
|
+
# nil means use normalize_path
|
|
368
|
+
normalize_path(raw_path)
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def raw_http_path(span)
|
|
373
|
+
path = span.attributes["http.target"] ||
|
|
374
|
+
span.attributes["url.path"] ||
|
|
375
|
+
extract_path_from_url(span)
|
|
376
|
+
path&.to_s&.split("?")&.first || "/"
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Normalize URL paths to prevent cardinality explosion.
|
|
380
|
+
# Replaces numeric IDs, UUIDs, and other high-cardinality segments with placeholders.
|
|
381
|
+
def normalize_path(path)
|
|
382
|
+
return "/" if path.nil? || path.empty?
|
|
383
|
+
|
|
384
|
+
segments = path.split("/")
|
|
385
|
+
normalized = segments.map do |segment|
|
|
386
|
+
next segment if segment.empty?
|
|
387
|
+
|
|
388
|
+
case segment
|
|
389
|
+
when /\A\d+\z/
|
|
390
|
+
# Pure numeric ID: /users/123 -> /users/:id
|
|
391
|
+
":id"
|
|
392
|
+
when /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
|
393
|
+
# UUID: /posts/550e8400-e29b-41d4-a716-446655440000 -> /posts/:uuid
|
|
394
|
+
":uuid"
|
|
395
|
+
when /\A[0-9a-f]{24}\z/i
|
|
396
|
+
# MongoDB ObjectId: /items/507f1f77bcf86cd799439011 -> /items/:id
|
|
397
|
+
":id"
|
|
398
|
+
when /\A[0-9a-f]{32,}\z/i
|
|
399
|
+
# Long hex strings (tokens, hashes): -> :token
|
|
400
|
+
":token"
|
|
401
|
+
else
|
|
402
|
+
segment
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
result = normalized.join("/")
|
|
407
|
+
result.empty? ? "/" : result
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def extract_path_from_url(span)
|
|
411
|
+
url = span.attributes["http.url"] || span.attributes["url.full"]
|
|
412
|
+
return nil unless url
|
|
413
|
+
|
|
414
|
+
URI.parse(url.to_s).path
|
|
415
|
+
rescue URI::InvalidURIError
|
|
416
|
+
nil
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def extract_cache_store(span)
|
|
420
|
+
store = span.attributes["store"].to_s
|
|
421
|
+
return "unknown" if store.empty?
|
|
422
|
+
|
|
423
|
+
downcased = store.downcase
|
|
424
|
+
CACHE_STORE_MAP.each do |pattern, name|
|
|
425
|
+
return name if downcased.include?(pattern)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Fallback: last segment, strip Store/Cache suffixes
|
|
429
|
+
short = store.split("::").last
|
|
430
|
+
.gsub(/CacheStore$|Store$|Cache$/, "")
|
|
431
|
+
.downcase
|
|
432
|
+
short.empty? ? "unknown" : short
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def extract_cache_operation(span)
|
|
436
|
+
base_op = span.name.to_s
|
|
437
|
+
.delete_prefix("cache_")
|
|
438
|
+
.delete_suffix(".active_support")
|
|
439
|
+
|
|
440
|
+
case base_op
|
|
441
|
+
when "read"
|
|
442
|
+
span.attributes["hit"] == true ? "read.hit" : "read.miss"
|
|
443
|
+
when "exist?"
|
|
444
|
+
span.attributes["exist"] == true ? "exist.hit" : "exist.miss"
|
|
445
|
+
else
|
|
446
|
+
base_op
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def extract_view_template(span)
|
|
451
|
+
identifier = span.attributes["identifier"]
|
|
452
|
+
return span.attributes["code.filepath"] || "unknown" unless identifier
|
|
453
|
+
|
|
454
|
+
path = identifier.to_s
|
|
455
|
+
if (idx = path.index("app/views/"))
|
|
456
|
+
path[(idx + "app/views/".length)..]
|
|
457
|
+
elsif (idx = path.index("app/"))
|
|
458
|
+
path[(idx + "app/".length)..]
|
|
459
|
+
else
|
|
460
|
+
File.basename(path)
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def extract_view_operation(span)
|
|
465
|
+
span.name.to_s
|
|
466
|
+
.delete_prefix("render_")
|
|
467
|
+
.delete_suffix(".action_view")
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent/map"
|
|
4
|
+
require_relative "metric_counter"
|
|
5
|
+
|
|
6
|
+
module Flare
|
|
7
|
+
# Thread-safe storage for metric aggregation.
|
|
8
|
+
# Uses Concurrent::Map for lock-free reads and writes.
|
|
9
|
+
class MetricStorage
|
|
10
|
+
def initialize
|
|
11
|
+
@storage = Concurrent::Map.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def increment(key, duration_ms:, error: false)
|
|
15
|
+
counter = @storage.compute_if_absent(key) { MetricCounter.new }
|
|
16
|
+
counter.increment(duration_ms: duration_ms, error: error)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Atomically retrieves and clears all metrics.
|
|
20
|
+
# Returns a frozen hash of MetricKey => counter data.
|
|
21
|
+
def drain
|
|
22
|
+
result = {}
|
|
23
|
+
@storage.keys.each do |key|
|
|
24
|
+
counter = @storage.delete(key)
|
|
25
|
+
result[key] = counter.to_h if counter
|
|
26
|
+
end
|
|
27
|
+
result.freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def size
|
|
31
|
+
@storage.size
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def empty?
|
|
35
|
+
@storage.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def [](key)
|
|
39
|
+
@storage[key]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|