flare 0.1.1 → 1.0.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.

Potentially problematic release.


This version of flare might be problematic. Click here for more details.

Files changed (56) hide show
  1. data/.document +5 -0
  2. data/.gitignore +22 -0
  3. data/LICENSE +20 -0
  4. data/README.rdoc +18 -0
  5. data/Rakefile +55 -0
  6. data/VERSION +1 -0
  7. data/flare.gemspec +66 -0
  8. data/lib/flare/active_record.rb +102 -0
  9. data/lib/flare/collection.rb +47 -0
  10. data/lib/flare/configuration.rb +64 -109
  11. data/lib/flare/index_builder.rb +24 -0
  12. data/lib/flare/session.rb +142 -0
  13. data/lib/flare/tasks.rb +18 -0
  14. data/lib/flare.rb +19 -408
  15. data/test/helper.rb +10 -0
  16. data/test/test_flare.rb +7 -0
  17. metadata +89 -228
  18. checksums.yaml +0 -7
  19. data/CHANGELOG.md +0 -5
  20. data/LICENSE.txt +0 -21
  21. data/README.md +0 -148
  22. data/app/controllers/flare/application_controller.rb +0 -22
  23. data/app/controllers/flare/jobs_controller.rb +0 -55
  24. data/app/controllers/flare/requests_controller.rb +0 -73
  25. data/app/controllers/flare/spans_controller.rb +0 -101
  26. data/app/helpers/flare/application_helper.rb +0 -168
  27. data/app/views/flare/jobs/index.html.erb +0 -69
  28. data/app/views/flare/jobs/show.html.erb +0 -323
  29. data/app/views/flare/requests/index.html.erb +0 -120
  30. data/app/views/flare/requests/show.html.erb +0 -498
  31. data/app/views/flare/spans/index.html.erb +0 -112
  32. data/app/views/flare/spans/show.html.erb +0 -184
  33. data/app/views/layouts/flare/application.html.erb +0 -126
  34. data/config/routes.rb +0 -20
  35. data/exe/flare +0 -9
  36. data/lib/flare/backoff_policy.rb +0 -73
  37. data/lib/flare/cli/doctor_command.rb +0 -129
  38. data/lib/flare/cli/output.rb +0 -45
  39. data/lib/flare/cli/setup_command.rb +0 -404
  40. data/lib/flare/cli/status_command.rb +0 -47
  41. data/lib/flare/cli.rb +0 -50
  42. data/lib/flare/engine.rb +0 -43
  43. data/lib/flare/http_metrics_config.rb +0 -101
  44. data/lib/flare/metric_counter.rb +0 -45
  45. data/lib/flare/metric_flusher.rb +0 -124
  46. data/lib/flare/metric_key.rb +0 -42
  47. data/lib/flare/metric_span_processor.rb +0 -470
  48. data/lib/flare/metric_storage.rb +0 -42
  49. data/lib/flare/metric_submitter.rb +0 -221
  50. data/lib/flare/source_location.rb +0 -113
  51. data/lib/flare/sqlite_exporter.rb +0 -279
  52. data/lib/flare/storage/sqlite.rb +0 -789
  53. data/lib/flare/storage.rb +0 -54
  54. data/lib/flare/version.rb +0 -5
  55. data/public/flare-assets/flare.css +0 -1245
  56. data/public/flare-assets/images/flipper.png +0 -0
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "concurrent/atomic/atomic_fixnum"
4
-
5
- module Flare
6
- # Thread-safe counter for metric aggregation.
7
- # Uses atomic operations for lock-free increments.
8
- #
9
- # Note: Durations are stored as integer milliseconds. Sub-millisecond
10
- # durations are truncated to 0. For very fast operations (e.g., cache hits),
11
- # the sum_ms may undercount actual time spent.
12
- class MetricCounter
13
- def initialize
14
- @count = Concurrent::AtomicFixnum.new(0)
15
- @sum_ms = Concurrent::AtomicFixnum.new(0)
16
- @error_count = Concurrent::AtomicFixnum.new(0)
17
- end
18
-
19
- def increment(duration_ms:, error: false)
20
- @count.increment
21
- @sum_ms.increment(duration_ms.to_i)
22
- @error_count.increment if error
23
- end
24
-
25
- def count
26
- @count.value
27
- end
28
-
29
- def sum_ms
30
- @sum_ms.value
31
- end
32
-
33
- def error_count
34
- @error_count.value
35
- end
36
-
37
- def to_h
38
- {
39
- count: @count.value,
40
- sum_ms: @sum_ms.value,
41
- error_count: @error_count.value
42
- }
43
- end
44
- end
45
- end
@@ -1,124 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "concurrent/timer_task"
4
- require "concurrent/executor/fixed_thread_pool"
5
-
6
- module Flare
7
- # Background threads that periodically drain in-memory metrics and submit
8
- # them via HTTP. Uses concurrent-ruby TimerTask + FixedThreadPool, matching
9
- # the pattern in Flipper's telemetry.
10
- #
11
- # Fork-safe: detects forked processes and restarts automatically.
12
- class MetricFlusher
13
- DEFAULT_INTERVAL = 60 # seconds
14
- DEFAULT_SHUTDOWN_TIMEOUT = 5 # seconds
15
-
16
- attr_reader :interval, :shutdown_timeout
17
-
18
- def initialize(storage:, submitter:, interval: DEFAULT_INTERVAL, shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT)
19
- @storage = storage
20
- @submitter = submitter
21
- @interval = interval
22
- @shutdown_timeout = shutdown_timeout
23
- @pid = $$
24
- @stopped = false
25
- end
26
-
27
- def start
28
- @stopped = false
29
-
30
- @pool = Concurrent::FixedThreadPool.new(1, {
31
- max_queue: 20,
32
- fallback_policy: :discard,
33
- name: "flare-metrics-submit-pool".freeze,
34
- })
35
-
36
- @timer = Concurrent::TimerTask.execute({
37
- execution_interval: @interval,
38
- name: "flare-metrics-drain-timer".freeze,
39
- }) { post_to_pool }
40
- end
41
-
42
- def stop
43
- return if @stopped
44
-
45
- @stopped = true
46
-
47
- Flare.log "Shutting down metrics flusher, draining remaining metrics..."
48
-
49
- if @timer
50
- @timer.shutdown
51
- @timer.wait_for_termination(1)
52
- @timer.kill unless @timer.shutdown?
53
- end
54
-
55
- if @pool
56
- post_to_pool # one last drain
57
- @pool.shutdown
58
- pool_terminated = @pool.wait_for_termination(@shutdown_timeout)
59
- @pool.kill unless pool_terminated
60
- end
61
-
62
- Flare.log "Metrics flusher stopped"
63
- end
64
-
65
- def restart
66
- @stopped = false
67
- stop
68
- start
69
- end
70
-
71
- # Manually trigger a flush (useful for testing or forced flushes).
72
- def flush_now
73
- return 0 unless @storage && @submitter
74
-
75
- drained = @storage.drain
76
- return 0 if drained.empty?
77
-
78
- count, error = @submitter.submit(drained)
79
- if error
80
- warn "[Flare] Metric submission error: #{error.message}"
81
- end
82
- count
83
- rescue => e
84
- warn "[Flare] Metric flush error: #{e.message}"
85
- 0
86
- end
87
-
88
- def running?
89
- @timer&.running? || false
90
- end
91
-
92
- # Re-initialize after fork. Called automatically by MetricSpanProcessor
93
- # on first span in the new process, or manually from Puma/Unicorn
94
- # after_fork hooks.
95
- def after_fork
96
- @pid = $$
97
- restart
98
- end
99
-
100
- private
101
-
102
- def post_to_pool
103
- drained = @storage.drain
104
- if drained.empty?
105
- Flare.log "No metrics to flush"
106
- return
107
- end
108
-
109
- Flare.log "Drained #{drained.size} metric keys for submission"
110
- @pool.post { submit_to_cloud(drained) }
111
- rescue => e
112
- warn "[Flare] Metric drain error: #{e.message}"
113
- end
114
-
115
- def submit_to_cloud(drained)
116
- _response, error = @submitter.submit(drained)
117
- if error
118
- warn "[Flare] Metric submission error: #{error.message}"
119
- end
120
- rescue => e
121
- warn "[Flare] Metric submission error: #{e.message}"
122
- end
123
- end
124
- end
@@ -1,42 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Flare
4
- # Identifies a unique metric for aggregation.
5
- # Immutable and hashable for use as Concurrent::Map keys.
6
- class MetricKey
7
- attr_reader :bucket, :namespace, :service, :target, :operation
8
-
9
- def initialize(bucket:, namespace:, service:, target:, operation:)
10
- @bucket = bucket
11
- @namespace = namespace.to_s.freeze
12
- @service = service.to_s.freeze
13
- @target = target&.to_s&.freeze
14
- @operation = operation.to_s.freeze
15
- freeze
16
- end
17
-
18
- def eql?(other)
19
- self.class.eql?(other.class) &&
20
- bucket == other.bucket &&
21
- namespace == other.namespace &&
22
- service == other.service &&
23
- target == other.target &&
24
- operation == other.operation
25
- end
26
- alias == eql?
27
-
28
- def hash
29
- [self.class, bucket, namespace, service, target, operation].hash
30
- end
31
-
32
- def to_h
33
- {
34
- bucket: bucket,
35
- namespace: namespace,
36
- service: service,
37
- target: target,
38
- operation: operation
39
- }
40
- end
41
- end
42
- end
@@ -1,470 +0,0 @@
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