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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +148 -0
  5. data/app/controllers/flare/application_controller.rb +22 -0
  6. data/app/controllers/flare/jobs_controller.rb +55 -0
  7. data/app/controllers/flare/requests_controller.rb +73 -0
  8. data/app/controllers/flare/spans_controller.rb +101 -0
  9. data/app/helpers/flare/application_helper.rb +168 -0
  10. data/app/views/flare/jobs/index.html.erb +69 -0
  11. data/app/views/flare/jobs/show.html.erb +323 -0
  12. data/app/views/flare/requests/index.html.erb +120 -0
  13. data/app/views/flare/requests/show.html.erb +498 -0
  14. data/app/views/flare/spans/index.html.erb +112 -0
  15. data/app/views/flare/spans/show.html.erb +184 -0
  16. data/app/views/layouts/flare/application.html.erb +126 -0
  17. data/config/routes.rb +20 -0
  18. data/exe/flare +9 -0
  19. data/lib/flare/backoff_policy.rb +73 -0
  20. data/lib/flare/cli/doctor_command.rb +129 -0
  21. data/lib/flare/cli/output.rb +45 -0
  22. data/lib/flare/cli/setup_command.rb +404 -0
  23. data/lib/flare/cli/status_command.rb +47 -0
  24. data/lib/flare/cli.rb +50 -0
  25. data/lib/flare/configuration.rb +121 -0
  26. data/lib/flare/engine.rb +43 -0
  27. data/lib/flare/http_metrics_config.rb +101 -0
  28. data/lib/flare/metric_counter.rb +45 -0
  29. data/lib/flare/metric_flusher.rb +124 -0
  30. data/lib/flare/metric_key.rb +42 -0
  31. data/lib/flare/metric_span_processor.rb +470 -0
  32. data/lib/flare/metric_storage.rb +42 -0
  33. data/lib/flare/metric_submitter.rb +221 -0
  34. data/lib/flare/source_location.rb +113 -0
  35. data/lib/flare/sqlite_exporter.rb +279 -0
  36. data/lib/flare/storage/sqlite.rb +789 -0
  37. data/lib/flare/storage.rb +54 -0
  38. data/lib/flare/version.rb +5 -0
  39. data/lib/flare.rb +411 -0
  40. data/public/flare-assets/flare.css +1245 -0
  41. data/public/flare-assets/images/flipper.png +0 -0
  42. metadata +240 -0
@@ -0,0 +1,789 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite3"
4
+ require "json"
5
+
6
+ module Flare
7
+ module Storage
8
+ class SQLite < Base
9
+ MISSING_PARENT_ID = "0000000000000000"
10
+
11
+ # Rails framework controller prefixes to filter (lowercase/underscored format)
12
+ RAILS_CONTROLLER_PREFIXES = %w[
13
+ active_storage/
14
+ action_mailbox/
15
+ rails/
16
+ flare/
17
+ ].freeze
18
+
19
+ def initialize(database_path)
20
+ @database_path = database_path
21
+ @mutex = Mutex.new
22
+ @setup = false
23
+ end
24
+
25
+ # List root spans that are HTTP requests (for the requests index)
26
+ def list_requests(status: nil, method: nil, name: nil, origin: nil, limit: 50, offset: 0)
27
+ # Find root spans with kind=server that have http.method property
28
+ conditions = ["s.parent_span_id = ?", "s.kind = ?"]
29
+ values = [MISSING_PARENT_ID, "server"]
30
+
31
+ # Filter by http.method property existing (makes it an HTTP request)
32
+ # We join with properties to filter
33
+
34
+ if status
35
+ case status
36
+ when "2xx"
37
+ conditions << "status_prop.value LIKE ?"
38
+ values << "2%"
39
+ when "3xx"
40
+ conditions << "status_prop.value LIKE ?"
41
+ values << "3%"
42
+ when "4xx"
43
+ conditions << "status_prop.value LIKE ?"
44
+ values << "4%"
45
+ when "5xx"
46
+ conditions << "status_prop.value LIKE ?"
47
+ values << "5%"
48
+ else
49
+ conditions << "status_prop.value = ?"
50
+ values << status.to_s
51
+ end
52
+ end
53
+
54
+ if method
55
+ conditions << "method_prop.value = ?"
56
+ values << "\"#{method}\""
57
+ end
58
+
59
+ if name
60
+ conditions << "s.name LIKE ?"
61
+ values << "%#{name}%"
62
+ end
63
+
64
+ if origin
65
+ if origin == "rails"
66
+ controller_conditions = RAILS_CONTROLLER_PREFIXES.map { "controller_prop.value LIKE ?" }
67
+ conditions << "(#{controller_conditions.join(" OR ")})"
68
+ RAILS_CONTROLLER_PREFIXES.each { |prefix| values << "%#{prefix}%" }
69
+ elsif origin == "app"
70
+ controller_conditions = RAILS_CONTROLLER_PREFIXES.map { "controller_prop.value LIKE ?" }
71
+ conditions << "(controller_prop.value IS NULL OR NOT (#{controller_conditions.join(" OR ")}))"
72
+ RAILS_CONTROLLER_PREFIXES.each { |prefix| values << "%#{prefix}%" }
73
+ end
74
+ end
75
+
76
+ where_clause = "WHERE #{conditions.join(" AND ")}"
77
+ values << limit
78
+ values << offset
79
+
80
+ rows = query_all(<<~SQL, values)
81
+ SELECT s.*,
82
+ method_prop.value as http_method,
83
+ status_prop.value as http_status,
84
+ target_prop.value as http_target,
85
+ controller_prop.value as controller,
86
+ action_prop.value as action
87
+ FROM flare_spans s
88
+ LEFT JOIN flare_properties method_prop ON method_prop.owner_type = 'Flare::Span' AND method_prop.owner_id = s.id AND method_prop.key = 'http.method'
89
+ LEFT JOIN flare_properties status_prop ON status_prop.owner_type = 'Flare::Span' AND status_prop.owner_id = s.id AND status_prop.key = 'http.status_code'
90
+ LEFT JOIN flare_properties target_prop ON target_prop.owner_type = 'Flare::Span' AND target_prop.owner_id = s.id AND target_prop.key = 'http.target'
91
+ LEFT JOIN flare_properties controller_prop ON controller_prop.owner_type = 'Flare::Span' AND controller_prop.owner_id = s.id AND controller_prop.key = 'code.namespace'
92
+ LEFT JOIN flare_properties action_prop ON action_prop.owner_type = 'Flare::Span' AND action_prop.owner_id = s.id AND action_prop.key = 'code.function'
93
+ #{where_clause}
94
+ AND method_prop.value IS NOT NULL
95
+ ORDER BY s.created_at DESC
96
+ LIMIT ? OFFSET ?
97
+ SQL
98
+
99
+ rows.map { |row| row_to_request(row) }
100
+ end
101
+
102
+ # List root spans that are jobs (for the jobs index)
103
+ def list_jobs(status: nil, name: nil, limit: 50, offset: 0)
104
+ # Find root spans with kind=consumer (ActiveJob processing)
105
+ conditions = ["s.parent_span_id = ?", "s.kind = ?"]
106
+ values = [MISSING_PARENT_ID, "consumer"]
107
+
108
+ if name
109
+ conditions << "s.name LIKE ?"
110
+ values << "%#{name}%"
111
+ end
112
+
113
+ where_clause = "WHERE #{conditions.join(" AND ")}"
114
+ values << limit
115
+ values << offset
116
+
117
+ rows = query_all(<<~SQL, values)
118
+ SELECT s.*,
119
+ job_class_prop.value as job_class,
120
+ queue_prop.value as queue_name
121
+ FROM flare_spans s
122
+ LEFT JOIN flare_properties job_class_prop ON job_class_prop.owner_type = 'Flare::Span' AND job_class_prop.owner_id = s.id AND job_class_prop.key = 'code.namespace'
123
+ LEFT JOIN flare_properties queue_prop ON queue_prop.owner_type = 'Flare::Span' AND queue_prop.owner_id = s.id AND queue_prop.key = 'messaging.destination'
124
+ #{where_clause}
125
+ ORDER BY s.created_at DESC
126
+ LIMIT ? OFFSET ?
127
+ SQL
128
+
129
+ rows.map { |row| row_to_job(row) }
130
+ end
131
+
132
+ # Span category patterns for filtering
133
+ SPAN_CATEGORIES = {
134
+ "queries" => ["sql.active_record", "mysql", "postgres", "sqlite"],
135
+ "cache" => ["cache_read.active_support", "cache_write.active_support", "cache_delete.active_support", "cache_exist?.active_support", "cache_fetch_hit.active_support"],
136
+ "views" => ["render_template.action_view", "render_partial.action_view", "render_layout.action_view", "render_collection.action_view"],
137
+ "http" => ["HTTP", "net_http"],
138
+ "mail" => ["deliver.action_mailer", "process.action_mailer"],
139
+ "redis" => ["redis"],
140
+ "exceptions" => [] # Handled specially via events
141
+ }.freeze
142
+
143
+ # Transaction statements to filter out from queries list
144
+ TRANSACTION_STATEMENTS = %w[BEGIN COMMIT ROLLBACK].freeze
145
+
146
+ # List spans by category (for the spans listing pages)
147
+ def list_spans_by_category(category, name: nil, limit: 50, offset: 0)
148
+ patterns = SPAN_CATEGORIES[category] || []
149
+ return [] if patterns.empty? && category != "exceptions"
150
+
151
+ if category == "exceptions"
152
+ # Exceptions are stored as events on spans, not as spans themselves
153
+ return list_exception_spans(name: name, limit: limit, offset: offset)
154
+ end
155
+
156
+ conditions = ["s.parent_span_id != ?"]
157
+ values = [MISSING_PARENT_ID]
158
+
159
+ # Build OR conditions for matching span names
160
+ pattern_conditions = patterns.map { "s.name LIKE ?" }
161
+ conditions << "(#{pattern_conditions.join(" OR ")})"
162
+ patterns.each { |p| values << "%#{p}%" }
163
+
164
+ if name
165
+ conditions << "s.name LIKE ?"
166
+ values << "%#{name}%"
167
+ end
168
+
169
+ # For queries, exclude transaction statements (BEGIN, COMMIT, ROLLBACK)
170
+ transaction_join = ""
171
+ if category == "queries"
172
+ transaction_join = "LEFT JOIN flare_properties stmt_prop ON stmt_prop.owner_type = 'Flare::Span' AND stmt_prop.owner_id = s.id AND stmt_prop.key = 'db.statement'"
173
+ exclusions = TRANSACTION_STATEMENTS.map { "?" }.join(", ")
174
+ conditions << "(stmt_prop.value IS NULL OR stmt_prop.value NOT IN (#{exclusions}))"
175
+ TRANSACTION_STATEMENTS.each { |stmt| values << "\"#{stmt}\"" }
176
+ end
177
+
178
+ where_clause = "WHERE #{conditions.join(" AND ")}"
179
+ values << limit
180
+ values << offset
181
+
182
+ rows = query_all(<<~SQL, values)
183
+ SELECT s.*,
184
+ root.trace_id as root_trace_id,
185
+ root.name as root_name,
186
+ root.kind as root_kind,
187
+ root_controller.value as root_controller,
188
+ root_action.value as root_action
189
+ FROM flare_spans s
190
+ LEFT JOIN flare_spans root ON root.trace_id = s.trace_id AND root.parent_span_id = '#{MISSING_PARENT_ID}'
191
+ LEFT JOIN flare_properties root_controller ON root_controller.owner_type = 'Flare::Span' AND root_controller.owner_id = root.id AND root_controller.key = 'code.namespace'
192
+ LEFT JOIN flare_properties root_action ON root_action.owner_type = 'Flare::Span' AND root_action.owner_id = root.id AND root_action.key = 'code.function'
193
+ #{transaction_join}
194
+ #{where_clause}
195
+ ORDER BY s.created_at DESC
196
+ LIMIT ? OFFSET ?
197
+ SQL
198
+
199
+ rows.map { |row| row_to_span_with_root(row) }
200
+ end
201
+
202
+ def count_spans_by_category(category, name: nil)
203
+ patterns = SPAN_CATEGORIES[category] || []
204
+ return 0 if patterns.empty? && category != "exceptions"
205
+
206
+ if category == "exceptions"
207
+ return count_exception_spans(name: name)
208
+ end
209
+
210
+ conditions = ["s.parent_span_id != ?"]
211
+ values = [MISSING_PARENT_ID]
212
+
213
+ pattern_conditions = patterns.map { "s.name LIKE ?" }
214
+ conditions << "(#{pattern_conditions.join(" OR ")})"
215
+ patterns.each { |p| values << "%#{p}%" }
216
+
217
+ if name
218
+ conditions << "s.name LIKE ?"
219
+ values << "%#{name}%"
220
+ end
221
+
222
+ # For queries, exclude transaction statements (BEGIN, COMMIT, ROLLBACK)
223
+ transaction_join = ""
224
+ if category == "queries"
225
+ transaction_join = "LEFT JOIN flare_properties stmt_prop ON stmt_prop.owner_type = 'Flare::Span' AND stmt_prop.owner_id = s.id AND stmt_prop.key = 'db.statement'"
226
+ exclusions = TRANSACTION_STATEMENTS.map { "?" }.join(", ")
227
+ conditions << "(stmt_prop.value IS NULL OR stmt_prop.value NOT IN (#{exclusions}))"
228
+ TRANSACTION_STATEMENTS.each { |stmt| values << "\"#{stmt}\"" }
229
+ end
230
+
231
+ where_clause = "WHERE #{conditions.join(" AND ")}"
232
+
233
+ row = query_one(<<~SQL, values)
234
+ SELECT COUNT(*) as count
235
+ FROM flare_spans s
236
+ #{transaction_join}
237
+ #{where_clause}
238
+ SQL
239
+
240
+ row ? row["count"] : 0
241
+ end
242
+
243
+ # Find a single span by its database ID
244
+ def find_span(id)
245
+ row = query_one(<<~SQL, [id])
246
+ SELECT s.*,
247
+ root.trace_id as root_trace_id,
248
+ root.name as root_name,
249
+ root.kind as root_kind,
250
+ root_controller.value as root_controller,
251
+ root_action.value as root_action
252
+ FROM flare_spans s
253
+ LEFT JOIN flare_spans root ON root.trace_id = s.trace_id AND root.parent_span_id = '#{MISSING_PARENT_ID}'
254
+ LEFT JOIN flare_properties root_controller ON root_controller.owner_type = 'Flare::Span' AND root_controller.owner_id = root.id AND root_controller.key = 'code.namespace'
255
+ LEFT JOIN flare_properties root_action ON root_action.owner_type = 'Flare::Span' AND root_action.owner_id = root.id AND root_action.key = 'code.function'
256
+ WHERE s.id = ?
257
+ SQL
258
+
259
+ return nil unless row
260
+
261
+ span = row_to_span_with_root(row)
262
+ span[:properties] = load_properties("Flare::Span", span[:id])
263
+ span[:events] = load_events_for_spans([span[:id]])[span[:id]] || []
264
+ span
265
+ end
266
+
267
+ # List spans that have exception events
268
+ def list_exception_spans(name: nil, limit: 50, offset: 0)
269
+ conditions = []
270
+ values = []
271
+
272
+ if name
273
+ conditions << "e.name LIKE ?"
274
+ values << "%#{name}%"
275
+ end
276
+
277
+ where_clause = conditions.any? ? "WHERE #{conditions.join(" AND ")}" : ""
278
+ values << limit
279
+ values << offset
280
+
281
+ rows = query_all(<<~SQL, values)
282
+ SELECT DISTINCT s.*,
283
+ root.trace_id as root_trace_id,
284
+ root.name as root_name,
285
+ root.kind as root_kind,
286
+ root_controller.value as root_controller,
287
+ root_action.value as root_action,
288
+ exc_type.value as exception_type,
289
+ exc_message.value as exception_message,
290
+ exc_stacktrace.value as exception_stacktrace
291
+ FROM flare_events e
292
+ JOIN flare_spans s ON s.id = e.span_id
293
+ LEFT JOIN flare_spans root ON root.trace_id = s.trace_id AND root.parent_span_id = '#{MISSING_PARENT_ID}'
294
+ LEFT JOIN flare_properties root_controller ON root_controller.owner_type = 'Flare::Span' AND root_controller.owner_id = root.id AND root_controller.key = 'code.namespace'
295
+ LEFT JOIN flare_properties root_action ON root_action.owner_type = 'Flare::Span' AND root_action.owner_id = root.id AND root_action.key = 'code.function'
296
+ LEFT JOIN flare_properties exc_type ON exc_type.owner_type = 'Flare::Event' AND exc_type.owner_id = e.id AND exc_type.key = 'exception.type'
297
+ LEFT JOIN flare_properties exc_message ON exc_message.owner_type = 'Flare::Event' AND exc_message.owner_id = e.id AND exc_message.key = 'exception.message'
298
+ LEFT JOIN flare_properties exc_stacktrace ON exc_stacktrace.owner_type = 'Flare::Event' AND exc_stacktrace.owner_id = e.id AND exc_stacktrace.key = 'exception.stacktrace'
299
+ #{where_clause}
300
+ ORDER BY e.created_at DESC
301
+ LIMIT ? OFFSET ?
302
+ SQL
303
+
304
+ rows.map { |row| row_to_span_with_root(row) }
305
+ end
306
+
307
+ def count_exception_spans(name: nil)
308
+ conditions = []
309
+ values = []
310
+
311
+ if name
312
+ conditions << "e.name LIKE ?"
313
+ values << "%#{name}%"
314
+ end
315
+
316
+ where_clause = conditions.any? ? "WHERE #{conditions.join(" AND ")}" : ""
317
+
318
+ row = query_one(<<~SQL, values)
319
+ SELECT COUNT(DISTINCT s.id) as count
320
+ FROM flare_events e
321
+ JOIN flare_spans s ON s.id = e.span_id
322
+ #{where_clause}
323
+ SQL
324
+
325
+ row ? row["count"] : 0
326
+ end
327
+
328
+ def count_jobs(status: nil, name: nil)
329
+ conditions = ["s.parent_span_id = ?", "s.kind = ?"]
330
+ values = [MISSING_PARENT_ID, "consumer"]
331
+
332
+ if name
333
+ conditions << "s.name LIKE ?"
334
+ values << "%#{name}%"
335
+ end
336
+
337
+ where_clause = "WHERE #{conditions.join(" AND ")}"
338
+
339
+ row = query_one(<<~SQL, values)
340
+ SELECT COUNT(*) as count
341
+ FROM flare_spans s
342
+ #{where_clause}
343
+ SQL
344
+
345
+ row ? row["count"] : 0
346
+ end
347
+
348
+ def count_requests(status: nil, method: nil, name: nil, origin: nil)
349
+ conditions = ["s.parent_span_id = ?", "s.kind = ?"]
350
+ values = [MISSING_PARENT_ID, "server"]
351
+
352
+ if status
353
+ case status
354
+ when "2xx"
355
+ conditions << "status_prop.value LIKE ?"
356
+ values << "2%"
357
+ when "3xx"
358
+ conditions << "status_prop.value LIKE ?"
359
+ values << "3%"
360
+ when "4xx"
361
+ conditions << "status_prop.value LIKE ?"
362
+ values << "4%"
363
+ when "5xx"
364
+ conditions << "status_prop.value LIKE ?"
365
+ values << "5%"
366
+ else
367
+ conditions << "status_prop.value = ?"
368
+ values << status.to_s
369
+ end
370
+ end
371
+
372
+ if method
373
+ conditions << "method_prop.value = ?"
374
+ values << "\"#{method}\""
375
+ end
376
+
377
+ if name
378
+ conditions << "s.name LIKE ?"
379
+ values << "%#{name}%"
380
+ end
381
+
382
+ if origin
383
+ if origin == "rails"
384
+ controller_conditions = RAILS_CONTROLLER_PREFIXES.map { "controller_prop.value LIKE ?" }
385
+ conditions << "(#{controller_conditions.join(" OR ")})"
386
+ RAILS_CONTROLLER_PREFIXES.each { |prefix| values << "%#{prefix}%" }
387
+ elsif origin == "app"
388
+ controller_conditions = RAILS_CONTROLLER_PREFIXES.map { "controller_prop.value LIKE ?" }
389
+ conditions << "(controller_prop.value IS NULL OR NOT (#{controller_conditions.join(" OR ")}))"
390
+ RAILS_CONTROLLER_PREFIXES.each { |prefix| values << "%#{prefix}%" }
391
+ end
392
+ end
393
+
394
+ where_clause = "WHERE #{conditions.join(" AND ")}"
395
+
396
+ row = query_one(<<~SQL, values)
397
+ SELECT COUNT(*) as count
398
+ FROM flare_spans s
399
+ LEFT JOIN flare_properties method_prop ON method_prop.owner_type = 'Flare::Span' AND method_prop.owner_id = s.id AND method_prop.key = 'http.method'
400
+ LEFT JOIN flare_properties status_prop ON status_prop.owner_type = 'Flare::Span' AND status_prop.owner_id = s.id AND status_prop.key = 'http.status_code'
401
+ LEFT JOIN flare_properties controller_prop ON controller_prop.owner_type = 'Flare::Span' AND controller_prop.owner_id = s.id AND controller_prop.key = 'code.namespace'
402
+ #{where_clause}
403
+ AND method_prop.value IS NOT NULL
404
+ SQL
405
+
406
+ row ? row["count"] : 0
407
+ end
408
+
409
+ # Find a job by trace_id (for the detail view)
410
+ def find_job(trace_id)
411
+ row = query_one(<<~SQL, [trace_id, MISSING_PARENT_ID, "consumer"])
412
+ SELECT s.*
413
+ FROM flare_spans s
414
+ WHERE s.trace_id = ? AND s.parent_span_id = ? AND s.kind = ?
415
+ SQL
416
+
417
+ return nil unless row
418
+
419
+ span = row_to_span(row)
420
+ span[:properties] = load_properties("Flare::Span", span[:id])
421
+ span
422
+ end
423
+
424
+ # Find a request by trace_id (for the detail view)
425
+ def find_request(trace_id)
426
+ row = query_one(<<~SQL, [trace_id, MISSING_PARENT_ID])
427
+ SELECT s.*
428
+ FROM flare_spans s
429
+ WHERE s.trace_id = ? AND s.parent_span_id = ?
430
+ SQL
431
+
432
+ return nil unless row
433
+
434
+ span = row_to_span(row)
435
+ span[:properties] = load_properties("Flare::Span", span[:id])
436
+ span
437
+ end
438
+
439
+ # Get all spans for a trace (for the waterfall view)
440
+ def spans_for_trace(trace_id)
441
+ rows = query_all(<<~SQL, [trace_id])
442
+ SELECT * FROM flare_spans
443
+ WHERE trace_id = ?
444
+ ORDER BY start_timestamp ASC
445
+ SQL
446
+
447
+ spans = rows.map { |row| row_to_span(row) }
448
+
449
+ # Load properties for all spans
450
+ span_ids = spans.map { |s| s[:id] }
451
+ if span_ids.any?
452
+ all_properties = load_properties_for_ids("Flare::Span", span_ids)
453
+ spans.each do |span|
454
+ span[:properties] = all_properties[span[:id]] || {}
455
+ end
456
+ end
457
+
458
+ # Load events for all spans
459
+ if span_ids.any?
460
+ all_events = load_events_for_spans(span_ids)
461
+ spans.each do |span|
462
+ span[:events] = all_events[span[:id]] || []
463
+ end
464
+ end
465
+
466
+ spans
467
+ end
468
+
469
+ # Load properties for a specific owner
470
+ def load_properties(owner_type, owner_id)
471
+ rows = query_all(<<~SQL, [owner_type, owner_id])
472
+ SELECT key, value, value_type FROM flare_properties
473
+ WHERE owner_type = ? AND owner_id = ?
474
+ SQL
475
+
476
+ rows.each_with_object({}) do |row, hash|
477
+ hash[row["key"]] = parse_property_value(row["value"], row["value_type"])
478
+ end
479
+ end
480
+
481
+ # Load properties for multiple owners at once
482
+ def load_properties_for_ids(owner_type, owner_ids)
483
+ return {} if owner_ids.empty?
484
+
485
+ placeholders = owner_ids.map { "?" }.join(", ")
486
+ rows = query_all(<<~SQL, [owner_type] + owner_ids)
487
+ SELECT owner_id, key, value, value_type FROM flare_properties
488
+ WHERE owner_type = ? AND owner_id IN (#{placeholders})
489
+ SQL
490
+
491
+ result = Hash.new { |h, k| h[k] = {} }
492
+ rows.each do |row|
493
+ result[row["owner_id"]][row["key"]] = parse_property_value(row["value"], row["value_type"])
494
+ end
495
+ result
496
+ end
497
+
498
+ # Load events for multiple spans at once
499
+ def load_events_for_spans(span_ids)
500
+ return {} if span_ids.empty?
501
+
502
+ placeholders = span_ids.map { "?" }.join(", ")
503
+ event_rows = query_all(<<~SQL, span_ids)
504
+ SELECT * FROM flare_events
505
+ WHERE span_id IN (#{placeholders})
506
+ SQL
507
+
508
+ # Group events by span_id
509
+ events_by_span = Hash.new { |h, k| h[k] = [] }
510
+ event_ids = []
511
+
512
+ event_rows.each do |row|
513
+ event = {
514
+ id: row["id"],
515
+ span_id: row["span_id"],
516
+ name: row["name"],
517
+ created_at: row["created_at"]
518
+ }
519
+ events_by_span[row["span_id"]] << event
520
+ event_ids << row["id"]
521
+ end
522
+
523
+ # Load properties for all events
524
+ if event_ids.any?
525
+ event_properties = load_properties_for_ids("Flare::Event", event_ids)
526
+ events_by_span.each do |_, events|
527
+ events.each do |event|
528
+ event[:properties] = event_properties[event[:id]] || {}
529
+ end
530
+ end
531
+ end
532
+
533
+ events_by_span
534
+ end
535
+
536
+ def prune(retention_hours:, max_spans:)
537
+ cutoff = (Time.now - (retention_hours * 3600)).iso8601(6)
538
+
539
+ # Delete old properties first (for old spans and events)
540
+ execute(<<~SQL, [cutoff])
541
+ DELETE FROM flare_properties WHERE owner_type = 'Flare::Span' AND owner_id IN (
542
+ SELECT id FROM flare_spans WHERE created_at < ?
543
+ )
544
+ SQL
545
+
546
+ execute(<<~SQL, [cutoff])
547
+ DELETE FROM flare_properties WHERE owner_type = 'Flare::Event' AND owner_id IN (
548
+ SELECT id FROM flare_events WHERE span_id IN (
549
+ SELECT id FROM flare_spans WHERE created_at < ?
550
+ )
551
+ )
552
+ SQL
553
+
554
+ # Delete old events
555
+ execute(<<~SQL, [cutoff])
556
+ DELETE FROM flare_events WHERE span_id IN (
557
+ SELECT id FROM flare_spans WHERE created_at < ?
558
+ )
559
+ SQL
560
+
561
+ # Delete old spans
562
+ execute(<<~SQL, [cutoff])
563
+ DELETE FROM flare_spans WHERE created_at < ?
564
+ SQL
565
+
566
+ # Also prune if over max_spans (keep newest)
567
+ execute(<<~SQL, [max_spans])
568
+ DELETE FROM flare_properties WHERE owner_type = 'Flare::Span' AND owner_id IN (
569
+ SELECT id FROM flare_spans
570
+ ORDER BY created_at DESC
571
+ LIMIT -1 OFFSET ?
572
+ )
573
+ SQL
574
+
575
+ execute(<<~SQL, [max_spans])
576
+ DELETE FROM flare_properties WHERE owner_type = 'Flare::Event' AND owner_id IN (
577
+ SELECT id FROM flare_events WHERE span_id IN (
578
+ SELECT id FROM flare_spans
579
+ ORDER BY created_at DESC
580
+ LIMIT -1 OFFSET ?
581
+ )
582
+ )
583
+ SQL
584
+
585
+ execute(<<~SQL, [max_spans])
586
+ DELETE FROM flare_events WHERE span_id IN (
587
+ SELECT id FROM flare_spans
588
+ ORDER BY created_at DESC
589
+ LIMIT -1 OFFSET ?
590
+ )
591
+ SQL
592
+
593
+ execute(<<~SQL, [max_spans])
594
+ DELETE FROM flare_spans WHERE id IN (
595
+ SELECT id FROM flare_spans
596
+ ORDER BY created_at DESC
597
+ LIMIT -1 OFFSET ?
598
+ )
599
+ SQL
600
+ end
601
+
602
+ def clear_all
603
+ execute("DELETE FROM flare_properties")
604
+ execute("DELETE FROM flare_events")
605
+ execute("DELETE FROM flare_spans")
606
+ end
607
+
608
+ private
609
+
610
+ def setup_database
611
+ # The SQLiteExporter creates the tables, but we ensure they exist here too
612
+ @mutex.synchronize do
613
+ return if @setup
614
+
615
+ db = connection
616
+ configure_pragmas(db)
617
+
618
+ db.execute(<<~SQL)
619
+ CREATE TABLE IF NOT EXISTS flare_spans (
620
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
621
+ name TEXT NOT NULL,
622
+ kind TEXT NOT NULL,
623
+ span_id TEXT NOT NULL,
624
+ trace_id TEXT NOT NULL,
625
+ parent_span_id TEXT,
626
+ start_timestamp INTEGER NOT NULL,
627
+ end_timestamp INTEGER NOT NULL,
628
+ total_recorded_properties INTEGER NOT NULL DEFAULT 0,
629
+ total_recorded_events INTEGER NOT NULL DEFAULT 0,
630
+ total_recorded_links INTEGER NOT NULL DEFAULT 0,
631
+ created_at TEXT NOT NULL,
632
+ updated_at TEXT NOT NULL
633
+ )
634
+ SQL
635
+
636
+ db.execute("CREATE INDEX IF NOT EXISTS idx_spans_span_id ON flare_spans(span_id)")
637
+ db.execute("CREATE INDEX IF NOT EXISTS idx_spans_trace_id ON flare_spans(trace_id)")
638
+ db.execute("CREATE INDEX IF NOT EXISTS idx_spans_parent_span_id ON flare_spans(parent_span_id)")
639
+ db.execute("CREATE INDEX IF NOT EXISTS idx_spans_created_at ON flare_spans(created_at)")
640
+
641
+ db.execute(<<~SQL)
642
+ CREATE TABLE IF NOT EXISTS flare_events (
643
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
644
+ span_id INTEGER NOT NULL,
645
+ name TEXT NOT NULL,
646
+ created_at TEXT NOT NULL,
647
+ updated_at TEXT NOT NULL,
648
+ FOREIGN KEY (span_id) REFERENCES flare_spans(id)
649
+ )
650
+ SQL
651
+
652
+ db.execute("CREATE INDEX IF NOT EXISTS idx_events_span_id ON flare_events(span_id)")
653
+
654
+ db.execute(<<~SQL)
655
+ CREATE TABLE IF NOT EXISTS flare_properties (
656
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
657
+ key TEXT NOT NULL,
658
+ value TEXT,
659
+ value_type INTEGER NOT NULL DEFAULT 0,
660
+ owner_type TEXT NOT NULL,
661
+ owner_id INTEGER NOT NULL,
662
+ created_at TEXT NOT NULL,
663
+ updated_at TEXT NOT NULL
664
+ )
665
+ SQL
666
+
667
+ db.execute("CREATE INDEX IF NOT EXISTS idx_properties_owner ON flare_properties(owner_type, owner_id)")
668
+ db.execute("CREATE INDEX IF NOT EXISTS idx_properties_key ON flare_properties(key)")
669
+
670
+ close_connection # avoid inheriting connection across fork
671
+ @setup = true
672
+ end
673
+ end
674
+
675
+ # Applies the same SQLite pragmas that ActiveRecord uses for good
676
+ # concurrency and performance with threaded/multi-process access.
677
+ def configure_pragmas(db)
678
+ db.execute("PRAGMA journal_mode=WAL")
679
+ db.execute("PRAGMA synchronous=NORMAL")
680
+ db.execute("PRAGMA mmap_size=134217728") # 128MB
681
+ db.execute("PRAGMA journal_size_limit=67108864") # 64MB
682
+ db.execute("PRAGMA cache_size=2000")
683
+ end
684
+
685
+ def connection
686
+ Thread.current[:flare_storage_db] ||= begin
687
+ dir = File.dirname(@database_path)
688
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
689
+ db = ::SQLite3::Database.new(@database_path, results_as_hash: true)
690
+ db.busy_timeout = 5000
691
+ db
692
+ end
693
+ end
694
+
695
+ def close_connection
696
+ if db = Thread.current[:flare_storage_db]
697
+ db.close rescue nil
698
+ Thread.current[:flare_storage_db] = nil
699
+ end
700
+ end
701
+
702
+ def execute(sql, values = [])
703
+ setup_database unless @setup
704
+ @mutex.synchronize do
705
+ connection.execute(sql, values)
706
+ end
707
+ end
708
+
709
+ def query_one(sql, values = [])
710
+ setup_database unless @setup
711
+ @mutex.synchronize do
712
+ connection.execute(sql, values).first
713
+ end
714
+ end
715
+
716
+ def query_all(sql, values = [])
717
+ setup_database unless @setup
718
+ @mutex.synchronize do
719
+ connection.execute(sql, values)
720
+ end
721
+ end
722
+
723
+ def row_to_span(row)
724
+ {
725
+ id: row["id"],
726
+ name: row["name"],
727
+ kind: row["kind"],
728
+ span_id: row["span_id"],
729
+ trace_id: row["trace_id"],
730
+ parent_span_id: row["parent_span_id"],
731
+ start_timestamp: row["start_timestamp"],
732
+ end_timestamp: row["end_timestamp"],
733
+ duration_ms: (row["end_timestamp"] - row["start_timestamp"]) / 1_000_000.0,
734
+ created_at: row["created_at"],
735
+ properties: {},
736
+ events: []
737
+ }
738
+ end
739
+
740
+ def row_to_request(row)
741
+ span = row_to_span(row)
742
+
743
+ # Add convenience accessors from the joined properties
744
+ span[:http_method] = parse_property_value(row["http_method"], 0)
745
+ span[:http_status] = parse_property_value(row["http_status"], 1)
746
+ span[:http_target] = parse_property_value(row["http_target"], 0)
747
+ span[:controller] = parse_property_value(row["controller"], 0)
748
+ span[:action] = parse_property_value(row["action"], 0)
749
+
750
+ span
751
+ end
752
+
753
+ def row_to_job(row)
754
+ span = row_to_span(row)
755
+
756
+ # Add convenience accessors from the joined properties
757
+ span[:job_class] = parse_property_value(row["job_class"], 0)
758
+ span[:queue_name] = parse_property_value(row["queue_name"], 0)
759
+
760
+ span
761
+ end
762
+
763
+ def row_to_span_with_root(row)
764
+ span = row_to_span(row)
765
+
766
+ # Add root span info for linking back to request/job
767
+ span[:root_trace_id] = row["root_trace_id"]
768
+ span[:root_name] = row["root_name"]
769
+ span[:root_kind] = row["root_kind"]
770
+ # For requests: controller#action, for jobs: code.namespace is the job class
771
+ span[:root_controller] = parse_property_value(row["root_controller"], 0)
772
+ span[:root_action] = parse_property_value(row["root_action"], 0)
773
+ span[:exception_type] = parse_property_value(row["exception_type"], 0) if row["exception_type"]
774
+ span[:exception_message] = parse_property_value(row["exception_message"], 0) if row["exception_message"]
775
+ span[:exception_stacktrace] = parse_property_value(row["exception_stacktrace"], 0) if row["exception_stacktrace"]
776
+
777
+ span
778
+ end
779
+
780
+ def parse_property_value(value, value_type)
781
+ return nil if value.nil?
782
+
783
+ JSON.parse(value)
784
+ rescue JSON::ParserError
785
+ value
786
+ end
787
+ end
788
+ end
789
+ end