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,323 @@
1
+ <%
2
+ # Get root span properties
3
+ root_props = @root_span ? @root_span[:properties] : {}
4
+ job_class = root_props["code.namespace"]
5
+ queue_name = root_props["messaging.destination"]
6
+ job_id = root_props["messaging.message.id"] || root_props["messaging.message_id"]
7
+
8
+ # Calculate total duration from root span
9
+ total_duration_ms = @root_span ? @root_span[:duration_ms] : 1
10
+ root_start = @root_span ? @root_span[:start_timestamp] : 0
11
+ started_at = @root_span ? Time.at(@root_span[:start_timestamp] / 1_000_000_000.0) : nil
12
+
13
+ # Extract job name from span name
14
+ job_display_name = job_class || @job[:name].to_s.sub(/ (process|publish)$/, '')
15
+
16
+ # Count span categories
17
+ span_counts = @child_spans.each_with_object(Hash.new(0)) do |span, counts|
18
+ counts[span_category(span)] += 1
19
+ end
20
+ %>
21
+
22
+ <div class="page-header">
23
+ <div class="detail-header">
24
+ <div class="detail-header-left">
25
+ <div class="detail-title">
26
+ <span class="badge badge-job">Job</span>
27
+ <h1><%= job_display_name %></h1>
28
+ </div>
29
+ <div class="detail-meta">
30
+ <div class="meta-item">
31
+ <span class="meta-label">Duration</span>
32
+ <span class="meta-value mono"><%= format_duration(total_duration_ms) %></span>
33
+ </div>
34
+ <div class="meta-item">
35
+ <span class="meta-label">Time</span>
36
+ <span class="meta-value"><%= started_at&.strftime("%b %d, %Y %H:%M:%S") %></span>
37
+ </div>
38
+ <% if queue_name %>
39
+ <div class="meta-item">
40
+ <span class="meta-label">Queue</span>
41
+ <span class="meta-value"><%= queue_name %></span>
42
+ </div>
43
+ <% end %>
44
+ <% if job_id %>
45
+ <div class="meta-item">
46
+ <span class="meta-label">Job ID</span>
47
+ <span class="meta-value mono"><%= job_id %></span>
48
+ </div>
49
+ <% end %>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ </div>
54
+
55
+ <div class="page-body">
56
+ <div class="card">
57
+ <div class="tabs">
58
+ <a class="tab active" onclick="showTab('timeline')">Timeline (<%= @child_spans.size %>)</a>
59
+ <a class="tab" onclick="showTab('details')">Details</a>
60
+ </div>
61
+
62
+ <div id="tab-details" class="tab-content">
63
+ <%
64
+ shown_keys = %w[code.namespace messaging.destination messaging.message.id messaging.message_id]
65
+ other_props = root_props.reject { |k, v| shown_keys.include?(k) || v.is_a?(Hash) || v.is_a?(Array) }
66
+ %>
67
+ <div class="details-sections">
68
+ <!-- Job Section -->
69
+ <div class="details-section">
70
+ <div class="section-header">
71
+ <div class="section-icon job">
72
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
73
+ <rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
74
+ <path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
75
+ </svg>
76
+ </div>
77
+ <span class="section-title">Job</span>
78
+ </div>
79
+ <div class="section-grid">
80
+ <div class="detail-item">
81
+ <span class="detail-label">Duration</span>
82
+ <span class="detail-value prominent"><%= format_duration(total_duration_ms) %></span>
83
+ </div>
84
+ <div class="detail-item">
85
+ <span class="detail-label">Time</span>
86
+ <span class="detail-value"><%= started_at&.strftime("%b %d, %Y at %H:%M:%S") %></span>
87
+ </div>
88
+ <% if job_class %>
89
+ <div class="detail-item">
90
+ <span class="detail-label">Job Class</span>
91
+ <span class="detail-value mono"><%= job_class %></span>
92
+ </div>
93
+ <% end %>
94
+ <% if job_id %>
95
+ <div class="detail-item full-width">
96
+ <span class="detail-label">Job ID</span>
97
+ <span class="detail-value mono secondary"><%= job_id %></span>
98
+ </div>
99
+ <% end %>
100
+ </div>
101
+ </div>
102
+
103
+ <!-- Queue Section -->
104
+ <% if queue_name %>
105
+ <div class="details-section">
106
+ <div class="section-header">
107
+ <div class="section-icon queue">
108
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
109
+ <line x1="8" y1="6" x2="21" y2="6"></line>
110
+ <line x1="8" y1="12" x2="21" y2="12"></line>
111
+ <line x1="8" y1="18" x2="21" y2="18"></line>
112
+ <line x1="3" y1="6" x2="3.01" y2="6"></line>
113
+ <line x1="3" y1="12" x2="3.01" y2="12"></line>
114
+ <line x1="3" y1="18" x2="3.01" y2="18"></line>
115
+ </svg>
116
+ </div>
117
+ <span class="section-title">Queue</span>
118
+ </div>
119
+ <div class="section-grid">
120
+ <div class="detail-item">
121
+ <span class="detail-label">Queue Name</span>
122
+ <span class="detail-value mono"><%= queue_name %></span>
123
+ </div>
124
+ </div>
125
+ </div>
126
+ <% end %>
127
+
128
+ <!-- Other Properties Section -->
129
+ <% if other_props.any? %>
130
+ <div class="details-section">
131
+ <div class="section-header">
132
+ <div class="section-icon other">
133
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
134
+ <circle cx="12" cy="12" r="1"></circle>
135
+ <circle cx="19" cy="12" r="1"></circle>
136
+ <circle cx="5" cy="12" r="1"></circle>
137
+ </svg>
138
+ </div>
139
+ <span class="section-title">Additional Properties</span>
140
+ </div>
141
+ <div class="section-grid">
142
+ <% other_props.each do |key, value| %>
143
+ <div class="detail-item">
144
+ <span class="detail-label"><%= key.to_s.split(".").last.titleize %></span>
145
+ <span class="detail-value mono truncate" title="<%= value %>"><%= value %></span>
146
+ </div>
147
+ <% end %>
148
+ </div>
149
+ </div>
150
+ <% end %>
151
+
152
+ <% if root_props.blank? %>
153
+ <div class="details-section">
154
+ <div class="empty-clues" style="padding: 1.25rem;">
155
+ <p>No details recorded.</p>
156
+ </div>
157
+ </div>
158
+ <% end %>
159
+ </div>
160
+ </div>
161
+
162
+ <div id="tab-timeline" class="tab-content active">
163
+ <% if @child_spans.blank? %>
164
+ <div class="empty-clues">
165
+ <i class="bi bi-clock-history" style="font-size: 2rem; color: var(--cb-warm-gray-light);"></i>
166
+ <p>No timeline events recorded for this job.</p>
167
+ </div>
168
+ <% else %>
169
+ <div class="legend">
170
+ <div class="legend-item clickable active" data-filter="all" onclick="filterSpans('all')"><div class="legend-color all"></div> All (<%= @child_spans.size %>)</div>
171
+ <% if span_counts["sql"] > 0 %><div class="legend-item clickable" data-filter="sql" onclick="filterSpans('sql')"><div class="legend-color sql"></div> SQL (<%= span_counts["sql"] %>)</div><% end %>
172
+ <% if span_counts["cache"] > 0 %><div class="legend-item clickable" data-filter="cache" onclick="filterSpans('cache')"><div class="legend-color cache"></div> Cache (<%= span_counts["cache"] %>)</div><% end %>
173
+ <% if span_counts["view"] > 0 %><div class="legend-item clickable" data-filter="view" onclick="filterSpans('view')"><div class="legend-color view"></div> View (<%= span_counts["view"] %>)</div><% end %>
174
+ <% if span_counts["http"] > 0 %><div class="legend-item clickable" data-filter="http" onclick="filterSpans('http')"><div class="legend-color http"></div> HTTP (<%= span_counts["http"] %>)</div><% end %>
175
+ <% if span_counts["mailer"] > 0 %><div class="legend-item clickable" data-filter="mailer" onclick="filterSpans('mailer')"><div class="legend-color mailer"></div> Mailer (<%= span_counts["mailer"] %>)</div><% end %>
176
+ <% if span_counts["job"] > 0 %><div class="legend-item clickable" data-filter="job" onclick="filterSpans('job')"><div class="legend-color job"></div> Job (<%= span_counts["job"] %>)</div><% end %>
177
+ <% if span_counts["controller"] > 0 %><div class="legend-item clickable" data-filter="controller" onclick="filterSpans('controller')"><div class="legend-color controller"></div> Controller (<%= span_counts["controller"] %>)</div><% end %>
178
+ <% if span_counts["other"] > 0 %><div class="legend-item clickable" data-filter="other" onclick="filterSpans('other')"><div class="legend-color other"></div> Other (<%= span_counts["other"] %>)</div><% end %>
179
+ <div style="margin-left: auto;">
180
+ <button type="button" onclick="collapseAll()" class="collapse-btn">
181
+ <i class="bi bi-arrows-collapse"></i>
182
+ Collapse All
183
+ </button>
184
+ </div>
185
+ </div>
186
+
187
+ <div style="padding: 0.75rem;">
188
+ <div class="waterfall-header">
189
+ <div>Event</div>
190
+ <div>Duration</div>
191
+ </div>
192
+
193
+ <div class="waterfall">
194
+ <% @child_spans.each_with_index do |span, index| %>
195
+ <%
196
+ offset_ns = span[:start_timestamp] - root_start
197
+ offset_ms = offset_ns / 1_000_000.0
198
+ left = (offset_ms / [total_duration_ms, 1].max * 100).clamp(0, 99.5)
199
+ width = (span[:duration_ms] / [total_duration_ms, 1].max * 100).clamp(0.5, [0.5, 100 - left].max)
200
+ category = span_category(span)
201
+
202
+ props = span[:properties] || {}
203
+ sql_statement = nil
204
+ display_name = if span[:name] == "instantiation.active_record"
205
+ class_name = props["class_name"] || "Record"
206
+ count = props["record_count"]
207
+ count ? "#{class_name} Instantiation (#{count})" : "#{class_name} Instantiation"
208
+ elsif category == "sql"
209
+ sql_statement = props["db.statement"]
210
+ props["name"] || span[:name]
211
+ elsif category == "cache"
212
+ key = props["key"]
213
+ op = span[:name].to_s.sub(".active_support", "").sub("cache_", "")
214
+ key ? "#{op}: #{key.to_s.truncate(80)}" : span[:name]
215
+ elsif category == "view"
216
+ identifier = props["identifier"] || props["code.filepath"]
217
+ if identifier
218
+ identifier.to_s.sub(/^.*\/app\/views\//, "")
219
+ else
220
+ span[:name]
221
+ end
222
+ elsif category == "http"
223
+ url = props["http.url"] || props["http.target"]
224
+ method = props["http.method"]
225
+ "#{method} #{url}".strip.presence || span[:name]
226
+ elsif category == "controller"
227
+ ns = props["code.namespace"]
228
+ fn = props["code.function"]
229
+ ns && fn ? "#{ns}##{fn}" : span[:name]
230
+ else
231
+ span[:name]
232
+ end
233
+ %>
234
+ <div class="waterfall-row" onclick="toggleSpan(<%= index %>)" id="row-<%= index %>" data-span-type="<%= category %>" <% if sql_statement.present? %>data-sql="<%= sql_statement.truncate(500).gsub('"', "'") %>"<% end %>>
235
+ <div class="waterfall-bar-container">
236
+ <div class="waterfall-bar <%= category %>" style="left: <%= left %>%; width: <%= width %>%;"></div>
237
+ <span class="waterfall-label"><%= display_name %></span>
238
+ <span class="waterfall-duration"><%= format_duration(span[:duration_ms]) %></span>
239
+ </div>
240
+ </div>
241
+ <div class="span-detail <%= category %>" id="span-<%= index %>">
242
+ <pre><code><%= format_content(span[:properties]) %></code></pre>
243
+ </div>
244
+ <% end %>
245
+ </div>
246
+ </div>
247
+ <% end %>
248
+ </div>
249
+ </div>
250
+ </div>
251
+
252
+ <script>
253
+ function showTab(tabName) {
254
+ document.querySelectorAll('.tab-content').forEach(function(el) { el.classList.remove('active'); });
255
+ document.querySelectorAll('.tab').forEach(function(el) { el.classList.remove('active'); });
256
+ document.getElementById('tab-' + tabName).classList.add('active');
257
+ event.target.classList.add('active');
258
+ }
259
+
260
+ function toggleSpan(index) {
261
+ const detail = document.getElementById('span-' + index);
262
+ const row = document.getElementById('row-' + index);
263
+ if (detail.classList.contains('visible')) {
264
+ detail.classList.remove('visible');
265
+ row.classList.remove('selected');
266
+ } else {
267
+ detail.classList.add('visible');
268
+ row.classList.add('selected');
269
+ }
270
+ }
271
+
272
+ function collapseAll() {
273
+ document.querySelectorAll('.span-detail.visible').forEach(function(el) { el.classList.remove('visible'); });
274
+ document.querySelectorAll('.waterfall-row.selected').forEach(function(el) { el.classList.remove('selected'); });
275
+ }
276
+
277
+ function filterSpans(filterType) {
278
+ document.querySelectorAll('.legend-item.clickable').forEach(function(el) { el.classList.remove('active'); });
279
+ document.querySelector('.legend-item[data-filter="' + filterType + '"]').classList.add('active');
280
+ document.querySelectorAll('.waterfall-row').forEach(function(row) {
281
+ const spanType = row.getAttribute('data-span-type');
282
+ if (filterType === 'all' || spanType === filterType) {
283
+ row.classList.remove('hidden');
284
+ } else {
285
+ row.classList.add('hidden');
286
+ row.classList.remove('selected');
287
+ }
288
+ });
289
+ document.querySelectorAll('.span-detail').forEach(function(detail) {
290
+ const index = detail.id.replace('span-', '');
291
+ const row = document.getElementById('row-' + index);
292
+ if (row.classList.contains('hidden')) {
293
+ detail.classList.add('hidden-by-filter');
294
+ detail.classList.remove('visible');
295
+ } else {
296
+ detail.classList.remove('hidden-by-filter');
297
+ }
298
+ });
299
+ }
300
+
301
+ // SQL tooltip
302
+ document.addEventListener('DOMContentLoaded', function() {
303
+ const sqlTooltip = document.getElementById('sql-tooltip');
304
+ let tooltipTimeout;
305
+ document.querySelectorAll('.waterfall-row[data-sql]').forEach(function(row) {
306
+ row.addEventListener('mouseenter', function() {
307
+ const sql = row.getAttribute('data-sql');
308
+ if (sql && !row.classList.contains('selected')) {
309
+ tooltipTimeout = setTimeout(function() {
310
+ sqlTooltip.textContent = sql;
311
+ sqlTooltip.classList.add('visible');
312
+ }, 200);
313
+ }
314
+ });
315
+ row.addEventListener('mouseleave', function() {
316
+ clearTimeout(tooltipTimeout);
317
+ sqlTooltip.classList.remove('visible');
318
+ });
319
+ });
320
+ });
321
+ </script>
322
+
323
+ <div id="sql-tooltip"></div>
@@ -0,0 +1,120 @@
1
+ <div class="page-header">
2
+ <h1 class="page-title"><%= page_title %></h1>
3
+ <div class="filters">
4
+ <%= form_tag(requests_path, method: :get, id: "filter-form") do %>
5
+ <%= select_tag :origin,
6
+ options_for_select([
7
+ ["All Origins", ""],
8
+ ["App", "app"],
9
+ ["Rails", "rails"]
10
+ ], current_origin),
11
+ class: "filter-select",
12
+ onchange: "this.form.submit()" %>
13
+ <%= select_tag :method,
14
+ options_for_select([
15
+ ["All Methods", ""],
16
+ ["GET", "GET"],
17
+ ["POST", "POST"],
18
+ ["PUT", "PUT"],
19
+ ["PATCH", "PATCH"],
20
+ ["DELETE", "DELETE"]
21
+ ], params[:method]),
22
+ class: "filter-select",
23
+ onchange: "this.form.submit()" %>
24
+ <%= select_tag :status,
25
+ options_for_select([
26
+ ["All Status", ""],
27
+ ["2xx Success", "2xx"],
28
+ ["3xx Redirect", "3xx"],
29
+ ["4xx Client Error", "4xx"],
30
+ ["5xx Server Error", "5xx"]
31
+ ], params[:status]),
32
+ class: "filter-select",
33
+ onchange: "this.form.submit()" %>
34
+ <%= text_field_tag :name, params[:name], placeholder: "Search...", class: "filter-select", style: "min-width: 200px;", onchange: "this.form.submit()" %>
35
+ <% end %>
36
+ </div>
37
+ </div>
38
+
39
+ <div class="page-body">
40
+ <div class="card">
41
+ <div class="card-body">
42
+ <% if @requests.blank? %>
43
+ <div class="empty-state">
44
+ <div class="empty-state-icon">
45
+ <i class="bi bi-speedometer2" style="font-size: 1.5rem;"></i>
46
+ </div>
47
+ <h3>No requests found</h3>
48
+ <p>Data will appear here as your application processes requests.</p>
49
+ </div>
50
+ <% else %>
51
+ <table class="data-table">
52
+ <thead>
53
+ <tr>
54
+ <th>Name</th>
55
+ <th style="width: 70px;">Verb</th>
56
+ <th style="width: 80px; text-align: right;">Status</th>
57
+ <th style="width: 100px; text-align: right;">Duration</th>
58
+ <th style="width: 120px; text-align: right;">Happened</th>
59
+ </tr>
60
+ </thead>
61
+ <tbody>
62
+ <% @requests.each do |req| %>
63
+ <% started_at = Time.at(req[:start_timestamp] / 1_000_000_000.0) rescue nil %>
64
+ <%
65
+ # Display controller#action if available, otherwise fall back to span name
66
+ display_name = if req[:controller] && req[:action]
67
+ "#{req[:controller]}##{req[:action]}"
68
+ else
69
+ req[:name]
70
+ end
71
+ %>
72
+ <tr onclick="navigateIfNotSelecting('<%= request_path(req[:trace_id]) %>')" style="cursor: pointer;">
73
+ <td class="path-cell">
74
+ <span class="action-name path-text" title="<%= display_name %>"><%= display_name %></span>
75
+ <span class="path-subtext" title="<%= req[:http_target] %>">
76
+ <%= req[:http_target] %>
77
+ </span>
78
+ </td>
79
+ <td>
80
+ <% method = req[:http_method] || "GET" %>
81
+ <span class="badge badge-<%= method.downcase %>"><%= method %></span>
82
+ </td>
83
+ <td style="text-align: right;">
84
+ <% status = req[:http_status] %>
85
+ <% status_class = case status.to_s
86
+ when /^2/ then "success"
87
+ when /^[45]/ then "error"
88
+ when /^3/ then "warning"
89
+ else "warning"
90
+ end %>
91
+ <span class="badge badge-status badge-<%= status_class %>"><%= status %></span>
92
+ </td>
93
+ <td class="duration" style="text-align: right;"><%= format_duration(req[:duration_ms]) %></td>
94
+ <td class="timestamp" style="text-align: right;"><%= started_at ? time_ago_in_words(started_at) : "-" %></td>
95
+ </tr>
96
+ <% end %>
97
+ </tbody>
98
+ </table>
99
+
100
+ <% if @total_count > 0 %>
101
+ <div class="pagination">
102
+ <div class="pagination-side">
103
+ <% if @has_prev %>
104
+ <%= link_to "Previous", requests_path(request.query_parameters.merge(offset: [@offset - 50, 0].max)), class: "pagination-link" %>
105
+ <% end %>
106
+ </div>
107
+ <div class="pagination-info">
108
+ <%= number_with_delimiter(@offset + 1) %> - <%= number_with_delimiter([@offset + @requests.size, @total_count].min) %> of <%= number_with_delimiter(@total_count) %>
109
+ </div>
110
+ <div class="pagination-side" style="text-align: right;">
111
+ <% if @has_next %>
112
+ <%= link_to "Next", requests_path(request.query_parameters.merge(offset: @offset + 50)), class: "pagination-link" %>
113
+ <% end %>
114
+ </div>
115
+ </div>
116
+ <% end %>
117
+ <% end %>
118
+ </div>
119
+ </div>
120
+ </div>