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,498 @@
|
|
|
1
|
+
<%
|
|
2
|
+
# Get root span properties
|
|
3
|
+
root_props = @root_span ? @root_span[:properties] : {}
|
|
4
|
+
http_method = root_props["http.method"]
|
|
5
|
+
http_status = root_props["http.status_code"]
|
|
6
|
+
http_target = root_props["http.target"]
|
|
7
|
+
controller = root_props["code.namespace"]
|
|
8
|
+
action = root_props["code.function"]
|
|
9
|
+
|
|
10
|
+
# Calculate total duration from root span
|
|
11
|
+
total_duration_ms = @root_span ? @root_span[:duration_ms] : 1
|
|
12
|
+
root_start = @root_span ? @root_span[:start_timestamp] : 0
|
|
13
|
+
started_at = @root_span ? Time.at(@root_span[:start_timestamp] / 1_000_000_000.0) : nil
|
|
14
|
+
|
|
15
|
+
# Count span categories
|
|
16
|
+
span_counts = @child_spans.each_with_object(Hash.new(0)) do |span, counts|
|
|
17
|
+
counts[span_category(span)] += 1
|
|
18
|
+
end
|
|
19
|
+
%>
|
|
20
|
+
|
|
21
|
+
<div class="page-header">
|
|
22
|
+
<div class="detail-header">
|
|
23
|
+
<div class="detail-header-left">
|
|
24
|
+
<div class="detail-title">
|
|
25
|
+
<span class="badge badge-<%= http_method.to_s.downcase %>"><%= http_method || "GET" %></span>
|
|
26
|
+
<% status_class = case http_status.to_s
|
|
27
|
+
when /^2/ then "success"
|
|
28
|
+
when /^[45]/ then "error"
|
|
29
|
+
when /^3/ then "warning"
|
|
30
|
+
else "warning"
|
|
31
|
+
end %>
|
|
32
|
+
<span class="badge badge-status badge-<%= status_class %>"><%= http_status %></span>
|
|
33
|
+
<h1><%= controller && action ? "#{controller}##{action}" : @request[:name] %></h1>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<div class="page-body">
|
|
40
|
+
<div class="card">
|
|
41
|
+
<div class="tabs">
|
|
42
|
+
<a class="tab active" onclick="showTab('timeline')">Timeline (<%= @child_spans.size %>)</a>
|
|
43
|
+
<a class="tab" onclick="showTab('details')">Details</a>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div id="tab-details" class="tab-content">
|
|
47
|
+
<%
|
|
48
|
+
# Group properties for cleaner display
|
|
49
|
+
user_agent = root_props["http.user_agent"]
|
|
50
|
+
route_pattern = root_props["http.route"]
|
|
51
|
+
|
|
52
|
+
# Properties to exclude from "other" section (already shown elsewhere)
|
|
53
|
+
shown_keys = %w[
|
|
54
|
+
http.method http.status_code http.target http.scheme http.host
|
|
55
|
+
code.namespace code.function http.route http.user_agent
|
|
56
|
+
]
|
|
57
|
+
other_props = root_props.reject { |k, v| shown_keys.include?(k) || v.is_a?(Hash) || v.is_a?(Array) }
|
|
58
|
+
%>
|
|
59
|
+
<div class="details-sections">
|
|
60
|
+
<!-- Request Section -->
|
|
61
|
+
<div class="details-section">
|
|
62
|
+
<div class="section-header">
|
|
63
|
+
<div class="section-icon request">
|
|
64
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
65
|
+
<path d="M21 12a9 9 0 0 1-9 9m9-9a9 9 0 0 0-9-9m9 9H3m9 9a9 9 0 0 1-9-9m9 9c1.66 0 3-4.03 3-9s-1.34-9-3-9m0 18c-1.66 0-3-4.03-3-9s1.34-9 3-9"/>
|
|
66
|
+
</svg>
|
|
67
|
+
</div>
|
|
68
|
+
<span class="section-title">Request</span>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="section-grid">
|
|
71
|
+
<div class="detail-item">
|
|
72
|
+
<span class="detail-label">Duration</span>
|
|
73
|
+
<span class="detail-value prominent"><%= format_duration(total_duration_ms) %></span>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="detail-item">
|
|
76
|
+
<span class="detail-label">Status</span>
|
|
77
|
+
<span class="detail-value">
|
|
78
|
+
<% status_dot_class = case http_status.to_s
|
|
79
|
+
when /^2/ then "success"
|
|
80
|
+
when /^[45]/ then "error"
|
|
81
|
+
else "warning"
|
|
82
|
+
end %>
|
|
83
|
+
<span class="status-dot <%= status_dot_class %>"></span><%= http_status %>
|
|
84
|
+
</span>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="detail-item">
|
|
87
|
+
<span class="detail-label">Method</span>
|
|
88
|
+
<span class="detail-value"><%= http_method || "GET" %></span>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="detail-item">
|
|
91
|
+
<span class="detail-label">Time</span>
|
|
92
|
+
<span class="detail-value"><%= started_at&.strftime("%b %d, %Y at %H:%M:%S") %></span>
|
|
93
|
+
</div>
|
|
94
|
+
<div class="detail-item full-width">
|
|
95
|
+
<span class="detail-label">Path</span>
|
|
96
|
+
<span class="detail-value mono"><%= http_target %></span>
|
|
97
|
+
</div>
|
|
98
|
+
<% if root_props["http.host"] %>
|
|
99
|
+
<div class="detail-item">
|
|
100
|
+
<span class="detail-label">Host</span>
|
|
101
|
+
<span class="detail-value mono"><%= root_props["http.host"] %></span>
|
|
102
|
+
</div>
|
|
103
|
+
<% end %>
|
|
104
|
+
<% if root_props["http.scheme"] %>
|
|
105
|
+
<div class="detail-item">
|
|
106
|
+
<span class="detail-label">Scheme</span>
|
|
107
|
+
<span class="detail-value"><%= root_props["http.scheme"] %></span>
|
|
108
|
+
</div>
|
|
109
|
+
<% end %>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<!-- Route Section -->
|
|
114
|
+
<% if controller || action || route_pattern %>
|
|
115
|
+
<div class="details-section">
|
|
116
|
+
<div class="section-header">
|
|
117
|
+
<div class="section-icon route">
|
|
118
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
119
|
+
<polyline points="16 18 22 12 16 6"></polyline>
|
|
120
|
+
<polyline points="8 6 2 12 8 18"></polyline>
|
|
121
|
+
</svg>
|
|
122
|
+
</div>
|
|
123
|
+
<span class="section-title">Route</span>
|
|
124
|
+
</div>
|
|
125
|
+
<div class="section-grid">
|
|
126
|
+
<% if controller %>
|
|
127
|
+
<div class="detail-item">
|
|
128
|
+
<span class="detail-label">Controller</span>
|
|
129
|
+
<span class="detail-value mono"><%= controller %></span>
|
|
130
|
+
</div>
|
|
131
|
+
<% end %>
|
|
132
|
+
<% if action %>
|
|
133
|
+
<div class="detail-item">
|
|
134
|
+
<span class="detail-label">Action</span>
|
|
135
|
+
<span class="detail-value mono"><%= action %></span>
|
|
136
|
+
</div>
|
|
137
|
+
<% end %>
|
|
138
|
+
<% if route_pattern %>
|
|
139
|
+
<div class="detail-item full-width">
|
|
140
|
+
<span class="detail-label">Route Pattern</span>
|
|
141
|
+
<span class="detail-value mono secondary"><%= route_pattern %></span>
|
|
142
|
+
</div>
|
|
143
|
+
<% end %>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
<% end %>
|
|
147
|
+
|
|
148
|
+
<!-- Client Section -->
|
|
149
|
+
<% if user_agent %>
|
|
150
|
+
<div class="details-section">
|
|
151
|
+
<div class="section-header">
|
|
152
|
+
<div class="section-icon client">
|
|
153
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
154
|
+
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
|
155
|
+
<line x1="8" y1="21" x2="16" y2="21"></line>
|
|
156
|
+
<line x1="12" y1="17" x2="12" y2="21"></line>
|
|
157
|
+
</svg>
|
|
158
|
+
</div>
|
|
159
|
+
<span class="section-title">Client</span>
|
|
160
|
+
</div>
|
|
161
|
+
<div class="section-grid">
|
|
162
|
+
<div class="detail-item full-width">
|
|
163
|
+
<span class="detail-label">User Agent</span>
|
|
164
|
+
<span class="detail-value secondary" style="font-size: 0.75rem;"><%= user_agent %></span>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
<% end %>
|
|
169
|
+
|
|
170
|
+
<!-- Other Properties Section -->
|
|
171
|
+
<% if other_props.any? %>
|
|
172
|
+
<div class="details-section">
|
|
173
|
+
<div class="section-header">
|
|
174
|
+
<div class="section-icon other">
|
|
175
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
176
|
+
<circle cx="12" cy="12" r="1"></circle>
|
|
177
|
+
<circle cx="19" cy="12" r="1"></circle>
|
|
178
|
+
<circle cx="5" cy="12" r="1"></circle>
|
|
179
|
+
</svg>
|
|
180
|
+
</div>
|
|
181
|
+
<span class="section-title">Additional Properties</span>
|
|
182
|
+
</div>
|
|
183
|
+
<div class="section-grid">
|
|
184
|
+
<% other_props.each do |key, value| %>
|
|
185
|
+
<div class="detail-item">
|
|
186
|
+
<span class="detail-label"><%= key.to_s.split(".").last.titleize %></span>
|
|
187
|
+
<span class="detail-value mono truncate" title="<%= value %>"><%= value %></span>
|
|
188
|
+
</div>
|
|
189
|
+
<% end %>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
<% end %>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<div id="tab-timeline" class="tab-content active">
|
|
197
|
+
<% if @child_spans.blank? %>
|
|
198
|
+
<div class="empty-clues">
|
|
199
|
+
<i class="bi bi-clock-history" style="font-size: 2rem; color: var(--cb-warm-gray-light);"></i>
|
|
200
|
+
<p>No timeline events recorded for this request.</p>
|
|
201
|
+
</div>
|
|
202
|
+
<% else %>
|
|
203
|
+
<div class="legend">
|
|
204
|
+
<div class="legend-item clickable active" data-filter="all" onclick="filterSpans('all')"><div class="legend-color all"></div> All (<%= @child_spans.size %>)</div>
|
|
205
|
+
<% 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 %>
|
|
206
|
+
<% 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 %>
|
|
207
|
+
<% 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 %>
|
|
208
|
+
<% 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 %>
|
|
209
|
+
<% 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 %>
|
|
210
|
+
<% 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 %>
|
|
211
|
+
<% 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 %>
|
|
212
|
+
<% 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 %>
|
|
213
|
+
<div style="margin-left: auto; display: flex; gap: 0.5rem;">
|
|
214
|
+
<button type="button" onclick="collapseAll()" class="copy-ai-btn">
|
|
215
|
+
<i class="bi bi-arrows-collapse"></i>
|
|
216
|
+
Collapse All
|
|
217
|
+
</button>
|
|
218
|
+
<button type="button" onclick="copyForAI()" class="copy-ai-btn" id="copy-ai-btn">
|
|
219
|
+
<i class="bi bi-clipboard" id="copy-ai-icon"></i>
|
|
220
|
+
<span id="copy-ai-text">Copy for AI</span>
|
|
221
|
+
</button>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<div style="padding: 0.75rem;">
|
|
226
|
+
<div class="waterfall-header">
|
|
227
|
+
<div>Event</div>
|
|
228
|
+
<div>Duration</div>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<div class="waterfall">
|
|
232
|
+
<% @child_spans.each_with_index do |span, index| %>
|
|
233
|
+
<%
|
|
234
|
+
offset_ns = span[:start_timestamp] - root_start
|
|
235
|
+
offset_ms = offset_ns / 1_000_000.0
|
|
236
|
+
left = (offset_ms / [total_duration_ms, 1].max * 100).clamp(0, 99.5)
|
|
237
|
+
width = (span[:duration_ms] / [total_duration_ms, 1].max * 100).clamp(0.5, [0.5, 100 - left].max)
|
|
238
|
+
category = span_category(span)
|
|
239
|
+
|
|
240
|
+
# Generate display name
|
|
241
|
+
props = span[:properties] || {}
|
|
242
|
+
sql_statement = nil
|
|
243
|
+
display_name = if span[:name] == "instantiation.active_record"
|
|
244
|
+
# Show "User Instantiation (23)" format
|
|
245
|
+
class_name = props["class_name"] || "Record"
|
|
246
|
+
count = props["record_count"]
|
|
247
|
+
count ? "#{class_name} Instantiation (#{count})" : "#{class_name} Instantiation"
|
|
248
|
+
elsif category == "sql"
|
|
249
|
+
sql_statement = props["db.statement"]
|
|
250
|
+
props["name"] || span[:name]
|
|
251
|
+
elsif category == "cache"
|
|
252
|
+
key = props["key"]
|
|
253
|
+
op = span[:name].to_s.sub(".active_support", "").sub("cache_", "")
|
|
254
|
+
key ? "#{op}: #{key.to_s.truncate(80)}" : span[:name]
|
|
255
|
+
elsif category == "view"
|
|
256
|
+
identifier = props["identifier"] || props["code.filepath"]
|
|
257
|
+
if identifier
|
|
258
|
+
identifier.to_s.sub(/^.*\/app\/views\//, "")
|
|
259
|
+
else
|
|
260
|
+
span[:name]
|
|
261
|
+
end
|
|
262
|
+
elsif category == "http"
|
|
263
|
+
url = props["http.url"] || props["http.target"]
|
|
264
|
+
method = props["http.method"]
|
|
265
|
+
"#{method} #{url}".strip.presence || span[:name]
|
|
266
|
+
elsif category == "controller"
|
|
267
|
+
ns = props["code.namespace"]
|
|
268
|
+
fn = props["code.function"]
|
|
269
|
+
ns && fn ? "#{ns}##{fn}" : span[:name]
|
|
270
|
+
else
|
|
271
|
+
span[:name]
|
|
272
|
+
end
|
|
273
|
+
%>
|
|
274
|
+
<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 %>>
|
|
275
|
+
<div class="waterfall-bar-container">
|
|
276
|
+
<div class="waterfall-bar <%= category %>" style="left: <%= left %>%; width: <%= width %>%;"></div>
|
|
277
|
+
<span class="waterfall-label"><%= display_name %></span>
|
|
278
|
+
<span class="waterfall-duration"><%= format_duration(span[:duration_ms]) %></span>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
<div class="span-detail <%= category %>" id="span-<%= index %>">
|
|
282
|
+
<pre><code><%= format_content(span[:properties]) %></code></pre>
|
|
283
|
+
</div>
|
|
284
|
+
<% end %>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
<% end %>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
<script>
|
|
293
|
+
// Data for Copy for AI feature
|
|
294
|
+
const requestData = <%= {
|
|
295
|
+
name: @request[:name],
|
|
296
|
+
duration_ms: total_duration_ms,
|
|
297
|
+
http_method: http_method,
|
|
298
|
+
http_status: http_status,
|
|
299
|
+
http_target: http_target,
|
|
300
|
+
controller: controller,
|
|
301
|
+
action: action
|
|
302
|
+
}.to_json.html_safe %>;
|
|
303
|
+
|
|
304
|
+
const spansData = <%= @child_spans.map { |span|
|
|
305
|
+
{
|
|
306
|
+
name: span[:name],
|
|
307
|
+
duration_ms: span[:duration_ms],
|
|
308
|
+
offset_ms: (span[:start_timestamp] - root_start) / 1_000_000.0,
|
|
309
|
+
properties: span[:properties]
|
|
310
|
+
}
|
|
311
|
+
}.to_json.html_safe %>;
|
|
312
|
+
|
|
313
|
+
function formatDuration(ms) {
|
|
314
|
+
if (ms === null || ms === undefined) return '0ms';
|
|
315
|
+
if (ms >= 1000) return (ms / 1000).toFixed(2) + 's';
|
|
316
|
+
return ms.toFixed(2) + 'ms';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function getSpanCategory(name) {
|
|
320
|
+
name = (name || '').toLowerCase();
|
|
321
|
+
if (name.includes('sql') || name.includes('mysql') || name.includes('postgres') || name.includes('sqlite')) return 'sql';
|
|
322
|
+
if (name.includes('cache')) return 'cache';
|
|
323
|
+
if (name.includes('render') || name.includes('view')) return 'view';
|
|
324
|
+
if (name.includes('http') || name.includes('net_http') || name.includes('faraday')) return 'http';
|
|
325
|
+
if (name.includes('mail')) return 'mailer';
|
|
326
|
+
if (name.includes('job') || name.includes('active_job')) return 'job';
|
|
327
|
+
if (name.includes('action_controller') || name.includes('process_action')) return 'controller';
|
|
328
|
+
return 'other';
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function copyForAI() {
|
|
332
|
+
let output = [];
|
|
333
|
+
|
|
334
|
+
output.push('> Given this Rails request trace, how would you improve performance? What information is missing that would help diagnose issues?');
|
|
335
|
+
output.push('');
|
|
336
|
+
|
|
337
|
+
const method = requestData.http_method || 'GET';
|
|
338
|
+
const status = requestData.http_status || '200';
|
|
339
|
+
output.push('# ' + method + ' ' + requestData.name + ' [' + status + '] - ' + formatDuration(requestData.duration_ms));
|
|
340
|
+
if (requestData.controller && requestData.action) {
|
|
341
|
+
output.push('Controller: ' + requestData.controller + '#' + requestData.action);
|
|
342
|
+
}
|
|
343
|
+
output.push('');
|
|
344
|
+
|
|
345
|
+
output.push('## Performance Summary');
|
|
346
|
+
output.push('');
|
|
347
|
+
output.push('- **Total:** ' + formatDuration(requestData.duration_ms));
|
|
348
|
+
|
|
349
|
+
const sqlSpans = spansData.filter(function(s) { return getSpanCategory(s.name) === 'sql'; });
|
|
350
|
+
if (sqlSpans.length > 0) {
|
|
351
|
+
const sqlTotalMs = sqlSpans.reduce(function(sum, s) { return sum + (s.duration_ms || 0); }, 0);
|
|
352
|
+
output.push('- **SQL:** ' + sqlSpans.length + ' queries, ' + formatDuration(sqlTotalMs) + ' total');
|
|
353
|
+
|
|
354
|
+
const slowest = sqlSpans.slice().sort(function(a, b) { return (b.duration_ms || 0) - (a.duration_ms || 0); }).slice(0, 3);
|
|
355
|
+
if (slowest.length > 0 && slowest[0].duration_ms > 1) {
|
|
356
|
+
const slowList = slowest.map(function(s) {
|
|
357
|
+
var stmt = (s.properties && s.properties['db.statement']) ? s.properties['db.statement'].substring(0, 50) : s.name;
|
|
358
|
+
return stmt + ' (' + formatDuration(s.duration_ms) + ')';
|
|
359
|
+
}).join(', ');
|
|
360
|
+
output.push('- **Slowest queries:** ' + slowList);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const viewSpans = spansData.filter(function(s) { return getSpanCategory(s.name) === 'view'; });
|
|
365
|
+
if (viewSpans.length > 0) {
|
|
366
|
+
const viewTotalMs = viewSpans.reduce(function(sum, s) { return sum + (s.duration_ms || 0); }, 0);
|
|
367
|
+
output.push('- **Views:** ' + viewSpans.length + ' renders, ' + formatDuration(viewTotalMs) + ' total');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
output.push('');
|
|
371
|
+
output.push('## Timeline');
|
|
372
|
+
output.push('');
|
|
373
|
+
|
|
374
|
+
spansData.forEach(function(span) {
|
|
375
|
+
const offset = formatDuration(span.offset_ms);
|
|
376
|
+
const duration = formatDuration(span.duration_ms);
|
|
377
|
+
const props = span.properties || {};
|
|
378
|
+
let name = span.name;
|
|
379
|
+
let extra = '';
|
|
380
|
+
|
|
381
|
+
const category = getSpanCategory(span.name);
|
|
382
|
+
if (category === 'sql' && props['db.statement']) {
|
|
383
|
+
name = 'SQL';
|
|
384
|
+
extra = props['db.statement'];
|
|
385
|
+
} else if (category === 'view' && props['identifier']) {
|
|
386
|
+
name = 'View';
|
|
387
|
+
extra = props['identifier'].replace(/.*\/app\/views\//, '');
|
|
388
|
+
} else if (category === 'http') {
|
|
389
|
+
name = 'HTTP';
|
|
390
|
+
extra = ((props['http.method'] || '') + ' ' + (props['http.url'] || props['http.target'] || '')).trim();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
let line = offset.padStart(8) + ' | ' + duration.padStart(8) + ' | ' + name;
|
|
394
|
+
if (extra) {
|
|
395
|
+
line += ': ' + extra;
|
|
396
|
+
}
|
|
397
|
+
output.push(line);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const text = output.join('\n');
|
|
401
|
+
|
|
402
|
+
navigator.clipboard.writeText(text).then(function() {
|
|
403
|
+
const btn = document.getElementById('copy-ai-btn');
|
|
404
|
+
const icon = document.getElementById('copy-ai-icon');
|
|
405
|
+
const label = document.getElementById('copy-ai-text');
|
|
406
|
+
btn.classList.add('copied');
|
|
407
|
+
icon.className = 'bi bi-check-lg';
|
|
408
|
+
label.textContent = 'Copied!';
|
|
409
|
+
setTimeout(function() {
|
|
410
|
+
btn.classList.remove('copied');
|
|
411
|
+
icon.className = 'bi bi-clipboard';
|
|
412
|
+
label.textContent = 'Copy for AI';
|
|
413
|
+
}, 2000);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function showTab(tabName) {
|
|
418
|
+
document.querySelectorAll('.tab-content').forEach(function(el) { el.classList.remove('active'); });
|
|
419
|
+
document.querySelectorAll('.tab').forEach(function(el) { el.classList.remove('active'); });
|
|
420
|
+
document.getElementById('tab-' + tabName).classList.add('active');
|
|
421
|
+
event.target.classList.add('active');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function toggleSpan(index) {
|
|
425
|
+
const detail = document.getElementById('span-' + index);
|
|
426
|
+
const row = document.getElementById('row-' + index);
|
|
427
|
+
|
|
428
|
+
if (detail.classList.contains('visible')) {
|
|
429
|
+
detail.classList.remove('visible');
|
|
430
|
+
row.classList.remove('selected');
|
|
431
|
+
} else {
|
|
432
|
+
detail.classList.add('visible');
|
|
433
|
+
row.classList.add('selected');
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function collapseAll() {
|
|
438
|
+
document.querySelectorAll('.span-detail.visible').forEach(function(el) {
|
|
439
|
+
el.classList.remove('visible');
|
|
440
|
+
});
|
|
441
|
+
document.querySelectorAll('.waterfall-row.selected').forEach(function(el) {
|
|
442
|
+
el.classList.remove('selected');
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function filterSpans(filterType) {
|
|
447
|
+
document.querySelectorAll('.legend-item.clickable').forEach(function(el) {
|
|
448
|
+
el.classList.remove('active');
|
|
449
|
+
});
|
|
450
|
+
document.querySelector('.legend-item[data-filter="' + filterType + '"]').classList.add('active');
|
|
451
|
+
|
|
452
|
+
document.querySelectorAll('.waterfall-row').forEach(function(row) {
|
|
453
|
+
const spanType = row.getAttribute('data-span-type');
|
|
454
|
+
if (filterType === 'all' || spanType === filterType) {
|
|
455
|
+
row.classList.remove('hidden');
|
|
456
|
+
} else {
|
|
457
|
+
row.classList.add('hidden');
|
|
458
|
+
row.classList.remove('selected');
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
document.querySelectorAll('.span-detail').forEach(function(detail) {
|
|
463
|
+
const index = detail.id.replace('span-', '');
|
|
464
|
+
const row = document.getElementById('row-' + index);
|
|
465
|
+
if (row.classList.contains('hidden')) {
|
|
466
|
+
detail.classList.add('hidden-by-filter');
|
|
467
|
+
detail.classList.remove('visible');
|
|
468
|
+
} else {
|
|
469
|
+
detail.classList.remove('hidden-by-filter');
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// SQL tooltip
|
|
475
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
476
|
+
const sqlTooltip = document.getElementById('sql-tooltip');
|
|
477
|
+
let tooltipTimeout;
|
|
478
|
+
|
|
479
|
+
document.querySelectorAll('.waterfall-row[data-sql]').forEach(function(row) {
|
|
480
|
+
row.addEventListener('mouseenter', function() {
|
|
481
|
+
const sql = row.getAttribute('data-sql');
|
|
482
|
+
if (sql && !row.classList.contains('selected')) {
|
|
483
|
+
tooltipTimeout = setTimeout(function() {
|
|
484
|
+
sqlTooltip.textContent = sql;
|
|
485
|
+
sqlTooltip.classList.add('visible');
|
|
486
|
+
}, 200);
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
row.addEventListener('mouseleave', function() {
|
|
491
|
+
clearTimeout(tooltipTimeout);
|
|
492
|
+
sqlTooltip.classList.remove('visible');
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
</script>
|
|
497
|
+
|
|
498
|
+
<div id="sql-tooltip"></div>
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
<%
|
|
2
|
+
current_path = send("#{@category}_spans_path")
|
|
3
|
+
%>
|
|
4
|
+
|
|
5
|
+
<div class="page-header">
|
|
6
|
+
<h1 class="page-title"><%= page_title %></h1>
|
|
7
|
+
<div class="filters">
|
|
8
|
+
<%= form_tag(current_path, method: :get, id: "filter-form") do %>
|
|
9
|
+
<%= text_field_tag :name, params[:name], placeholder: "Search...", class: "filter-select", style: "min-width: 200px;", onchange: "this.form.submit()" %>
|
|
10
|
+
<% end %>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<div class="page-body">
|
|
15
|
+
<div class="card">
|
|
16
|
+
<div class="card-body">
|
|
17
|
+
<% if @spans.blank? %>
|
|
18
|
+
<div class="empty-state">
|
|
19
|
+
<div class="empty-state-icon">
|
|
20
|
+
<i class="bi bi-search" style="font-size: 1.5rem;"></i>
|
|
21
|
+
</div>
|
|
22
|
+
<h3>No <%= page_title.downcase %> found</h3>
|
|
23
|
+
<p>Data will appear here as your application processes requests.</p>
|
|
24
|
+
</div>
|
|
25
|
+
<% else %>
|
|
26
|
+
<table class="data-table">
|
|
27
|
+
<thead>
|
|
28
|
+
<tr>
|
|
29
|
+
<th><%= page_title.singularize %></th>
|
|
30
|
+
<% if @category == "http" %>
|
|
31
|
+
<th style="width: 70px; text-align: right;">Verb</th>
|
|
32
|
+
<th style="width: 70px; text-align: right;">Status</th>
|
|
33
|
+
<% elsif @category == "cache" %>
|
|
34
|
+
<th style="width: 100px; text-align: right;">Operation</th>
|
|
35
|
+
<% end %>
|
|
36
|
+
<th style="width: 100px; text-align: right;">Duration</th>
|
|
37
|
+
<th style="width: 120px; text-align: right;">Happened</th>
|
|
38
|
+
</tr>
|
|
39
|
+
</thead>
|
|
40
|
+
<tbody>
|
|
41
|
+
<% @spans.each do |span| %>
|
|
42
|
+
<% started_at = Time.at(span[:start_timestamp] / 1_000_000_000.0) rescue nil %>
|
|
43
|
+
<% display_info = span_display_info(span, @category) %>
|
|
44
|
+
<tr onclick="navigateIfNotSelecting('<%= span_path(span[:id], category: @category) %>')" style="cursor: pointer;">
|
|
45
|
+
<td class="path-cell">
|
|
46
|
+
<span class="span-name path-text" title="<%= display_info[:primary] %>"><%= display_info[:primary] %></span>
|
|
47
|
+
<% if display_info[:secondary].present? %>
|
|
48
|
+
<span class="path-subtext"><%= display_info[:secondary] %></span>
|
|
49
|
+
<% end %>
|
|
50
|
+
</td>
|
|
51
|
+
<% if @category == "http" %>
|
|
52
|
+
<td style="text-align: right;">
|
|
53
|
+
<% if display_info[:http_method] %>
|
|
54
|
+
<span class="badge badge-<%= display_info[:http_method].to_s.downcase %>"><%= display_info[:http_method] %></span>
|
|
55
|
+
<% end %>
|
|
56
|
+
</td>
|
|
57
|
+
<td style="text-align: right;">
|
|
58
|
+
<% if display_info[:http_status] %>
|
|
59
|
+
<%
|
|
60
|
+
status_class = case display_info[:http_status].to_s
|
|
61
|
+
when /^2/ then "success"
|
|
62
|
+
when /^[45]/ then "error"
|
|
63
|
+
when /^3/ then "warning"
|
|
64
|
+
else "warning"
|
|
65
|
+
end
|
|
66
|
+
%>
|
|
67
|
+
<span class="badge badge-status badge-<%= status_class %>"><%= display_info[:http_status] %></span>
|
|
68
|
+
<% end %>
|
|
69
|
+
</td>
|
|
70
|
+
<% elsif @category == "cache" %>
|
|
71
|
+
<td style="text-align: right;">
|
|
72
|
+
<% if display_info[:cache_op] %>
|
|
73
|
+
<%
|
|
74
|
+
cache_class = case display_info[:cache_op].to_s
|
|
75
|
+
when /hit/ then "success"
|
|
76
|
+
when /write/ then "warning"
|
|
77
|
+
when /delete/ then "error"
|
|
78
|
+
else "other"
|
|
79
|
+
end
|
|
80
|
+
%>
|
|
81
|
+
<span class="badge badge-status badge-<%= cache_class %>"><%= display_info[:cache_op] %></span>
|
|
82
|
+
<% end %>
|
|
83
|
+
</td>
|
|
84
|
+
<% end %>
|
|
85
|
+
<td class="duration" style="text-align: right;"><%= format_duration(span[:duration_ms]) %></td>
|
|
86
|
+
<td class="timestamp" style="text-align: right;"><%= started_at ? time_ago_in_words(started_at) : "-" %></td>
|
|
87
|
+
</tr>
|
|
88
|
+
<% end %>
|
|
89
|
+
</tbody>
|
|
90
|
+
</table>
|
|
91
|
+
|
|
92
|
+
<% if @total_count > 0 %>
|
|
93
|
+
<div class="pagination">
|
|
94
|
+
<div class="pagination-side">
|
|
95
|
+
<% if @has_prev %>
|
|
96
|
+
<%= link_to "Previous", send("#{@category}_spans_path", request.query_parameters.merge(offset: [@offset - 50, 0].max)), class: "pagination-link" %>
|
|
97
|
+
<% end %>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="pagination-info">
|
|
100
|
+
<%= number_with_delimiter(@offset + 1) %> - <%= number_with_delimiter([@offset + @spans.size, @total_count].min) %> of <%= number_with_delimiter(@total_count) %>
|
|
101
|
+
</div>
|
|
102
|
+
<div class="pagination-side" style="text-align: right;">
|
|
103
|
+
<% if @has_next %>
|
|
104
|
+
<%= link_to "Next", send("#{@category}_spans_path", request.query_parameters.merge(offset: @offset + 50)), class: "pagination-link" %>
|
|
105
|
+
<% end %>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
<% end %>
|
|
109
|
+
<% end %>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|