dontbugme 0.1.3 → 0.1.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3c88f3e8468ff48582e0dca26ddeea1ebb66b378c57a7e96b888bdb0ca991d7
4
- data.tar.gz: ae3982763c6d291f3f1a4873fb168c9718dc296ace4053779b7c82d3f1b4b271
3
+ metadata.gz: 5b8559d5544650453a07a48f7a123270fa34537e4980167b96c5391cd21bd081
4
+ data.tar.gz: 8e87d2da3525e8def1b4fbd7d8f7c8e0c5941cc176c835a0cedd8f9635ead888
5
5
  SHA512:
6
- metadata.gz: 5c3d6729e5e7fac64ca625d0e1e4a0ec54498f124acc3cfafa47afff56839e269aa64795f1060b5bddae665f58b94ee57247ec9d26de200e7f44976cd9d726ca
7
- data.tar.gz: 86133c069405f2c75d9605fed551f2a5361ce5a0072fcfbcfde77d878da7f2934e4d0fe6ac0cb35c6301a0ddba1e9f1dcc7f44b879013338aaeaa5d0e8d8e7bb
6
+ metadata.gz: cc02e325d106483e274c53cd77258edc868bbd03241ec20aaa3e4962b5bfeea8f9345bb2e2d5b0d1fc04155e2f6caaa60a540211392f1c7a9a54c6c0ad4864f3
7
+ data.tar.gz: 45642d97da0b241ffd31a3b0585227195b17320e675ac9f2af671f07d38f7af11620ae9e11141a76aa8f4ae220505ddf2d1638b9f112e859ae0351e134a89038
data/README.md CHANGED
@@ -114,18 +114,20 @@ puts trace.to_timeline
114
114
 
115
115
  ### Manual Spans and Snapshots
116
116
 
117
- Add custom spans and snapshots within a trace:
117
+ Add custom spans and snapshots within a trace. Spans capture the **return value** by default so you can see outputs in the UI:
118
118
 
119
119
  ```ruby
120
120
  trace = Dontbugme.trace("checkout flow") do
121
121
  Dontbugme.span("Calculate tax") do
122
- tax = order.calculate_tax
122
+ tax = order.calculate_tax # output shown in UI
123
123
  end
124
124
  Dontbugme.snapshot(user: user.attributes.slice("id", "email"), total: order.total)
125
125
  Dontbugme.tag(customer_tier: "enterprise")
126
126
  end
127
127
  ```
128
128
 
129
+ Use `capture_output: false` to skip capturing the return value for sensitive data.
130
+
129
131
  ### Span Categories
130
132
 
131
133
  Access spans by category for assertions or analysis:
@@ -185,6 +187,11 @@ Dontbugme.configure do |config|
185
187
  config.recording_mode = :always
186
188
  config.capture_sql_binds = true
187
189
  config.source_mode = :full
190
+
191
+ # Capture outputs for debugging (development only)
192
+ config.capture_span_output = true # return values from Dontbugme.span
193
+ config.capture_http_body = true # HTTP response bodies
194
+ config.capture_redis_return_values = true # Redis command return values
188
195
  end
189
196
  ```
190
197
 
@@ -24,7 +24,7 @@ module Dontbugme
24
24
  @trace_a = params[:a].present? ? store.find_trace(params[:a]) : nil
25
25
  @trace_b = params[:b].present? ? store.find_trace(params[:b]) : nil
26
26
  @diff_output = if @trace_a && @trace_b
27
- Formatters::Diff.format(@trace_a, @trace_b)
27
+ Dontbugme::Formatters::Diff.format(@trace_a, @trace_b)
28
28
  else
29
29
  nil
30
30
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dontbugme
4
+ module TracesHelper
5
+ def span_category_badge_class(category)
6
+ case category.to_sym
7
+ when :sql then 'badge-sql'
8
+ when :http then 'badge-http'
9
+ when :redis then 'badge-redis'
10
+ when :cache then 'badge-cache'
11
+ when :mailer then 'badge-mailer'
12
+ when :enqueue then 'badge-enqueue'
13
+ when :custom then 'badge-custom'
14
+ when :snapshot then 'badge-snapshot'
15
+ else 'badge-default'
16
+ end
17
+ end
18
+
19
+ OUTPUT_KEYS = %w[output result response_body].freeze
20
+
21
+ def span_output(span)
22
+ return nil if span.payload.blank?
23
+
24
+ payload = span.payload.is_a?(Hash) ? span.payload : {}
25
+ key = OUTPUT_KEYS.find { |k| payload[k.to_sym] || payload[k] }
26
+ key ? (payload[key.to_sym] || payload[key]) : nil
27
+ end
28
+
29
+ def format_span_payload(span)
30
+ return [] if span.payload.blank?
31
+
32
+ payload = span.payload.is_a?(Hash) ? span.payload : {}
33
+ payload.map do |key, value|
34
+ next if value.nil?
35
+ next if OUTPUT_KEYS.include?(key.to_s)
36
+
37
+ display_value = case value
38
+ when Array then value.map { |v| v.is_a?(String) ? v : v.inspect }.join(', ')
39
+ when Hash then value.inspect
40
+ else value.to_s
41
+ end
42
+ display_value = "#{display_value[0, 500]}..." if display_value.length > 500
43
+ [key.to_s.tr('_', ' ').capitalize, display_value]
44
+ end.compact
45
+ end
46
+
47
+ def format_span_detail_for_display(span)
48
+ span.detail
49
+ end
50
+
51
+ def truncate_detail(str, max_len = 80)
52
+ return '' if str.blank?
53
+ str = str.to_s.strip
54
+ return str if str.length <= max_len
55
+ "#{str[0, max_len - 3]}..."
56
+ end
57
+ end
58
+ end
@@ -15,4 +15,4 @@
15
15
  <p class="empty">Enter two trace IDs above to see what changed between two executions.</p>
16
16
  <% end %>
17
17
 
18
- <p style="margin-top: 16px;"><%= link_to '← Back to traces', root_path %></p>
18
+ <p style="margin-top: 16px;"><%= link_to '← Back to traces', root_path, class: 'btn btn-secondary', style: 'display: inline-block;' %></p>
@@ -1,5 +1,5 @@
1
1
  <%= form_with url: root_path, method: :get, local: true, class: 'filters' do |f| %>
2
- <input type="text" name="q" value="<%= params[:q] %>" placeholder="Search identifier..." style="min-width: 200px;">
2
+ <input type="text" name="q" value="<%= params[:q] %>" placeholder="Search identifier..." style="min-width: 220px;">
3
3
  <select name="status">
4
4
  <option value="">All statuses</option>
5
5
  <option value="success" <%= params[:status] == 'success' ? 'selected' : '' %>>Success</option>
@@ -14,15 +14,15 @@
14
14
  <button type="submit">Filter</button>
15
15
  <% end %>
16
16
 
17
- <div class="card">
17
+ <div class="card traces-list">
18
18
  <% if @traces.any? %>
19
19
  <% @traces.each do |trace| %>
20
- <div class="trace-row">
21
- <span class="trace-id"><%= link_to trace.id, trace_path(trace.id) %></span>
20
+ <%= link_to trace_path(trace.id), class: 'trace-row trace-row-link' do %>
21
+ <span class="trace-id"><%= trace.id %></span>
22
22
  <span class="trace-identifier"><%= trace.identifier %></span>
23
23
  <span class="badge <%= trace.status == :success ? 'badge-success' : 'badge-error' %>"><%= trace.status %></span>
24
24
  <span class="trace-duration"><%= trace.duration_ms ? "#{trace.duration_ms.round}ms" : '-' %></span>
25
- </div>
25
+ <% end %>
26
26
  <% end %>
27
27
  <% else %>
28
28
  <p class="empty">No traces found. Run your app and execute some jobs or requests.</p>
@@ -1,15 +1,96 @@
1
- <div class="card">
2
- <h2 style="margin: 0 0 8px; font-size: 16px;"><%= @trace.identifier %></h2>
3
- <p style="margin: 0 0 16px; color: #6b7280; font-size: 12px;">
4
- <%= @trace.id %> · <%= @trace.status %> · <%= @trace.duration_ms ? "#{@trace.duration_ms.round}ms" : 'N/A' %>
5
- <% if @trace.correlation_id.present? %>
6
- · <%= link_to "Follow chain", root_path(correlation_id: @trace.correlation_id) %>
1
+ <div class="trace-header card">
2
+ <div class="trace-header-top">
3
+ <h2 class="trace-title"><%= @trace.identifier %></h2>
4
+ <div class="trace-meta">
5
+ <span class="badge <%= @trace.status == :success ? 'badge-success' : 'badge-error' %>"><%= @trace.status %></span>
6
+ <span class="trace-meta-item"><%= @trace.duration_ms ? "#{@trace.duration_ms.round}ms" : 'N/A' %></span>
7
+ <span class="trace-meta-item"><%= @trace.id %></span>
8
+ <% if @trace.correlation_id.present? %>
9
+ <%= link_to "Follow chain", root_path(correlation_id: @trace.correlation_id), class: 'trace-link' %>
10
+ <% end %>
11
+ </div>
12
+ </div>
13
+ <div class="trace-actions">
14
+ <%= link_to '← Back to traces', root_path, class: 'btn btn-secondary' %>
15
+ <%= link_to 'Compare with another', diff_path(a: @trace.id), class: 'btn btn-secondary' %>
16
+ </div>
17
+ </div>
18
+
19
+ <% if params[:only].present? %>
20
+ <p class="filter-hint">Filtering by: <strong><%= params[:only] %></strong> · <%= link_to 'Show all', trace_path(@trace.id) %></p>
21
+ <% end %>
22
+
23
+ <div class="timeline-viz">
24
+ <div class="timeline-bar" style="--total-ms: <%= [@trace.duration_ms || 1, 1].max %>;">
25
+ <% @trace.raw_spans.each do |span| %>
26
+ <% next if params[:only].present? && span.category.to_s != params[:only] %>
27
+ <% width = span.duration_ms.to_f > 0 ? [(span.duration_ms / (@trace.duration_ms || 1) * 100).round(1), 2].max : 2 %>
28
+ <span class="timeline-segment badge-<%= span.category %>" style="width: <%= width %>%;" title="<%= span.category %> <%= span.duration_ms.round(1) %>ms"></span>
7
29
  <% end %>
8
- </p>
9
- <div class="timeline"><%= Formatters::Timeline.format(@trace) %></div>
30
+ </div>
31
+ </div>
32
+
33
+ <div class="spans-list">
34
+ <% spans_to_show = params[:only].present? ? @trace.raw_spans.select { |s| s.category.to_s == params[:only] } : @trace.raw_spans %>
35
+ <% spans_to_show.each_with_index do |span, idx| %>
36
+ <details class="span-card card" data-category="<%= span.category %>">
37
+ <summary class="span-summary">
38
+ <span class="span-offset"><%= span.started_at.to_f.round(1) %>ms</span>
39
+ <span class="span-duration"><%= span.duration_ms ? "#{span.duration_ms.round(1)}ms" : '-' %></span>
40
+ <span class="badge <%= span_category_badge_class(span.category) %>"><%= span.category.to_s.upcase %></span>
41
+ <span class="span-operation"><%= span.operation %></span>
42
+ <span class="span-detail-preview"><%= truncate_detail(span.detail, 80) %></span>
43
+ </summary>
44
+ <div class="span-details">
45
+ <% if span_output(span).present? %>
46
+ <div class="span-section span-output-section">
47
+ <h4>Output <button type="button" class="copy-btn" data-copy-target="output-<%= idx %>" title="Copy">Copy</button></h4>
48
+ <pre class="code-block output-block" id="output-<%= idx %>"><code><%= h(span_output(span)) %></code></pre>
49
+ </div>
50
+ <% end %>
51
+ <div class="span-section">
52
+ <h4>Detail <button type="button" class="copy-btn" data-copy-target="detail-<%= idx %>" title="Copy">Copy</button></h4>
53
+ <pre class="code-block" id="detail-<%= idx %>"><code><%= h(format_span_detail_for_display(span)) %></code></pre>
54
+ </div>
55
+ <% if format_span_payload(span).any? %>
56
+ <div class="span-section">
57
+ <h4>Inputs</h4>
58
+ <dl class="payload-list">
59
+ <% format_span_payload(span).each do |key, value| %>
60
+ <div class="payload-row">
61
+ <dt><%= key %></dt>
62
+ <dd><pre class="payload-value"><%= h(value) %></pre></dd>
63
+ </div>
64
+ <% end %>
65
+ </dl>
66
+ </div>
67
+ <% end %>
68
+ <% if span.source.present? %>
69
+ <div class="span-section">
70
+ <h4>Source</h4>
71
+ <code class="source-location"><%= h(span.source) %></code>
72
+ </div>
73
+ <% end %>
74
+ </div>
75
+ </details>
76
+ <% end %>
10
77
  </div>
11
78
 
12
- <p style="margin-top: 16px;">
13
- <%= link_to '← Back to traces', root_path %> ·
14
- <%= link_to 'Compare with another trace', diff_path(a: @trace.id) %>
15
- </p>
79
+ <% if @trace.truncated_spans_count.to_i.positive? %>
80
+ <p class="truncated-notice">+ <%= @trace.truncated_spans_count %> additional spans truncated</p>
81
+ <% end %>
82
+
83
+ <div class="trace-footer">
84
+ <p>Filter by category: <%= link_to 'All', trace_path(@trace.id) %> · <%= link_to 'SQL', trace_path(@trace.id, only: 'sql') %> · <%= link_to 'HTTP', trace_path(@trace.id, only: 'http') %> · <%= link_to 'Redis', trace_path(@trace.id, only: 'redis') %> · <%= link_to 'Cache', trace_path(@trace.id, only: 'cache') %> · <%= link_to 'Mailer', trace_path(@trace.id, only: 'mailer') %> · <%= link_to 'Enqueue', trace_path(@trace.id, only: 'enqueue') %></p>
85
+ </div>
86
+
87
+ <% if @trace.error.present? %>
88
+ <div class="card error-card">
89
+ <h3>Error</h3>
90
+ <pre class="code-block error-detail"><%= h(@trace.error[:message] || @trace.error['message']) %></pre>
91
+ <% bt = @trace.error[:backtrace] || @trace.error['backtrace'] %>
92
+ <% if bt.present? %>
93
+ <pre class="code-block backtrace"><%= Array(bt).map { |l| h(l) }.join("\n") %></pre>
94
+ <% end %>
95
+ </div>
96
+ <% end %>
@@ -3,41 +3,110 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <%= csrf_meta_tags %>
6
7
  <title>Dontbugme</title>
7
8
  <style>
8
9
  * { box-sizing: border-box; }
9
- body { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; font-size: 13px; line-height: 1.5; color: #1a1a1a; background: #f8f9fa; margin: 0; padding: 0; }
10
- .container { max-width: 1000px; margin: 0 auto; padding: 24px; }
11
- header { background: #fff; border-bottom: 1px solid #e5e7eb; padding: 16px 24px; margin-bottom: 24px; }
12
- header h1 { margin: 0; font-size: 18px; font-weight: 600; }
10
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.5; color: #1a1a1a; background: #0f172a; margin: 0; padding: 0; }
11
+ .container { max-width: 1200px; margin: 0 auto; padding: 24px; }
12
+ header { background: #1e293b; border-bottom: 1px solid #334155; padding: 16px 24px; margin-bottom: 24px; }
13
+ header h1 { margin: 0; font-size: 18px; font-weight: 600; color: #f8fafc; }
13
14
  header nav { margin-top: 12px; }
14
- header a { color: #4b5563; text-decoration: none; margin-right: 16px; }
15
- header a:hover { color: #111; }
16
- header a.active { color: #111; font-weight: 600; }
17
- .card { background: #fff; border-radius: 6px; border: 1px solid #e5e7eb; padding: 16px; margin-bottom: 16px; }
18
- .trace-row { display: flex; align-items: center; gap: 16px; padding: 0 0 12px; border-bottom: 1px solid #f3f4f6; }
19
- .trace-row:last-child { border-bottom: none; padding-bottom: 0; }
20
- .trace-row:hover { background: #f9fafb; margin: 0 -16px; padding: 0 16px 12px; }
21
- .trace-id { font-family: monospace; font-size: 12px; color: #6b7280; min-width: 140px; }
22
- .trace-id a { color: #2563eb; text-decoration: none; }
15
+ header a { color: #94a3b8; text-decoration: none; margin-right: 16px; }
16
+ header a:hover { color: #f8fafc; }
17
+ header a.active { color: #38bdf8; font-weight: 600; }
18
+ .card { background: #1e293b; border-radius: 8px; border: 1px solid #334155; padding: 20px; margin-bottom: 16px; }
19
+ .trace-row { display: flex; align-items: center; gap: 16px; padding: 12px 16px; border-radius: 6px; transition: background 0.15s; }
20
+ .trace-row:hover { background: #334155; }
21
+ .trace-row-link { text-decoration: none; color: inherit; display: flex; }
22
+ .trace-id { font-family: "JetBrains Mono", "Fira Code", monospace; font-size: 12px; color: #64748b; min-width: 140px; }
23
+ .trace-id a { color: #38bdf8; text-decoration: none; }
23
24
  .trace-id a:hover { text-decoration: underline; }
24
- .trace-identifier { flex: 1; }
25
- .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; }
26
- .badge-success { background: #d1fae5; color: #065f46; }
27
- .badge-error { background: #fee2e2; color: #991b1b; }
28
- .trace-duration { color: #6b7280; min-width: 50px; }
25
+ .trace-identifier { flex: 1; color: #e2e8f0; }
26
+ .badge { display: inline-block; padding: 3px 10px; border-radius: 6px; font-size: 11px; font-weight: 600; }
27
+ .badge-success { background: #064e3b; color: #34d399; }
28
+ .badge-error { background: #7f1d1d; color: #f87171; }
29
+ .badge-sql { background: #1e3a5f; color: #7dd3fc; }
30
+ .badge-http { background: #422006; color: #fbbf24; }
31
+ .badge-redis { background: #4c1d95; color: #c4b5fd; }
32
+ .badge-cache { background: #14532d; color: #86efac; }
33
+ .badge-mailer { background: #701a75; color: #f0abfc; }
34
+ .badge-enqueue { background: #1e293b; color: #94a3b8; }
35
+ .badge-custom { background: #0f172a; color: #cbd5e1; }
36
+ .badge-snapshot { background: #312e81; color: #a5b4fc; }
37
+ .badge-default { background: #334155; color: #94a3b8; }
38
+ .trace-duration { color: #64748b; min-width: 60px; font-family: monospace; }
29
39
  .filters { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
30
- .filters input, .filters select { padding: 8px 12px; border: 1px solid #e5e7eb; border-radius: 4px; font-size: 13px; }
31
- .filters button { padding: 8px 16px; background: #111; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; }
32
- .filters button:hover { background: #333; }
33
- .timeline { font-size: 12px; white-space: pre-wrap; font-family: ui-monospace, monospace; }
34
- .span-line { padding: 4px 0; }
35
- .span-source { color: #6b7280; padding-left: 12px; font-size: 11px; }
40
+ .filters input, .filters select { padding: 10px 14px; border: 1px solid #334155; border-radius: 6px; font-size: 13px; background: #1e293b; color: #e2e8f0; }
41
+ .filters button { padding: 10px 20px; background: #38bdf8; color: #0f172a; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 600; }
42
+ .filters button:hover { background: #7dd3fc; }
36
43
  .diff-form { display: flex; gap: 12px; align-items: center; margin-bottom: 24px; }
37
- .diff-form input { flex: 1; padding: 8px 12px; border: 1px solid #e5e7eb; border-radius: 4px; }
38
- .diff-output { white-space: pre-wrap; font-size: 12px; background: #1f2937; color: #e5e7eb; padding: 16px; border-radius: 6px; overflow-x: auto; }
39
- .empty { color: #6b7280; padding: 24px; text-align: center; }
44
+ .diff-form input { flex: 1; padding: 10px 14px; border: 1px solid #334155; border-radius: 6px; background: #1e293b; color: #e2e8f0; }
45
+ .diff-output { white-space: pre-wrap; font-size: 12px; background: #0f172a; color: #e2e8f0; padding: 20px; border-radius: 8px; overflow-x: auto; font-family: monospace; }
46
+ .empty { color: #64748b; padding: 24px; text-align: center; }
47
+ .trace-header { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; gap: 16px; }
48
+ .trace-title { margin: 0 0 8px; font-size: 18px; font-weight: 600; color: #f8fafc; }
49
+ .trace-meta { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
50
+ .trace-meta-item { color: #64748b; font-size: 13px; }
51
+ .trace-link { color: #38bdf8; text-decoration: none; }
52
+ .trace-link:hover { text-decoration: underline; }
53
+ .trace-actions { display: flex; gap: 8px; }
54
+ .btn { padding: 8px 16px; border-radius: 6px; font-size: 13px; text-decoration: none; font-weight: 500; }
55
+ .btn-secondary { background: #334155; color: #e2e8f0; }
56
+ .btn-secondary:hover { background: #475569; }
57
+ .timeline-viz { margin-bottom: 24px; }
58
+ .timeline-bar { display: flex; height: 24px; border-radius: 6px; overflow: hidden; background: #334155; }
59
+ .timeline-segment { min-width: 2px; transition: opacity 0.2s; }
60
+ .timeline-segment:hover { opacity: 0.8; }
61
+ .spans-list { display: flex; flex-direction: column; gap: 8px; }
62
+ .span-card { padding: 0; overflow: hidden; }
63
+ .span-card summary { padding: 14px 20px; cursor: pointer; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; list-style: none; }
64
+ .span-card summary::-webkit-details-marker { display: none; }
65
+ .span-card summary::after { content: '▶'; color: #64748b; font-size: 10px; margin-left: auto; transition: transform 0.2s; }
66
+ .span-card[open] summary::after { transform: rotate(90deg); }
67
+ .span-card summary:hover { background: #334155; }
68
+ .span-offset { font-family: monospace; font-size: 12px; color: #64748b; min-width: 50px; }
69
+ .span-duration { font-family: monospace; font-size: 12px; color: #38bdf8; min-width: 55px; }
70
+ .span-operation { color: #94a3b8; font-size: 12px; }
71
+ .span-detail-preview { color: #cbd5e1; font-size: 13px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
72
+ .span-details { padding: 0 20px 20px; border-top: 1px solid #334155; }
73
+ .span-section { margin-top: 16px; }
74
+ .span-section h4 { margin: 0 0 8px; font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
75
+ .code-block { background: #0f172a; color: #e2e8f0; padding: 16px; border-radius: 6px; overflow-x: auto; font-family: "JetBrains Mono", "Fira Code", monospace; font-size: 12px; line-height: 1.6; margin: 0; white-space: pre-wrap; word-break: break-all; }
76
+ .payload-list { margin: 0; }
77
+ .payload-row { display: grid; grid-template-columns: 140px 1fr; gap: 12px; margin-bottom: 12px; align-items: start; }
78
+ .payload-row dt { margin: 0; color: #64748b; font-size: 12px; font-weight: 500; }
79
+ .payload-value { margin: 0; padding: 8px 12px; background: #0f172a; border-radius: 4px; font-size: 12px; color: #e2e8f0; overflow-x: auto; white-space: pre-wrap; font-family: monospace; }
80
+ .source-location { display: block; padding: 8px 12px; background: #0f172a; border-radius: 4px; font-size: 11px; color: #94a3b8; }
81
+ .filter-hint { color: #94a3b8; margin-bottom: 16px; font-size: 13px; }
82
+ .filter-hint a { color: #38bdf8; }
83
+ .trace-footer { margin-top: 24px; }
84
+ .trace-footer p { color: #64748b; font-size: 13px; }
85
+ .trace-footer a { color: #38bdf8; }
86
+ .truncated-notice { color: #64748b; font-size: 13px; margin-top: 8px; }
87
+ .error-card { border-color: #7f1d1d; }
88
+ .error-card h3 { color: #f87171; margin: 0 0 12px; }
89
+ .error-detail { color: #f87171; }
90
+ .backtrace { font-size: 11px; color: #94a3b8; }
91
+ .span-section h4 { display: flex; align-items: center; gap: 8px; }
92
+ .span-output-section .output-block { border-left: 3px solid #38bdf8; }
93
+ .copy-btn { background: #334155; color: #94a3b8; border: none; padding: 4px 10px; border-radius: 4px; font-size: 11px; cursor: pointer; }
94
+ .copy-btn:hover { background: #475569; color: #e2e8f0; }
40
95
  </style>
96
+ <script>
97
+ document.querySelectorAll('.copy-btn').forEach(function(btn) {
98
+ btn.addEventListener('click', function() {
99
+ var target = document.getElementById(this.dataset.copyTarget);
100
+ if (!target) return;
101
+ var text = target.textContent;
102
+ navigator.clipboard.writeText(text).then(function() {
103
+ var orig = btn.textContent;
104
+ btn.textContent = 'Copied!';
105
+ setTimeout(function() { btn.textContent = orig; }, 1500);
106
+ });
107
+ });
108
+ });
109
+ </script>
41
110
  </head>
42
111
  <body>
43
112
  <header>
@@ -49,7 +118,7 @@
49
118
  </nav>
50
119
  </div>
51
120
  </header>
52
- <main class="container">
121
+ <main class="container" style="color: #e2e8f0;">
53
122
  <%= yield %>
54
123
  </main>
55
124
  </body>
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- Rails.application.routes.draw do
4
- root to: 'traces#index', as: :root
3
+ Dontbugme::Engine.routes.draw do
4
+ root to: 'traces#index'
5
5
  get 'diff', to: 'traces#diff', as: :diff
6
6
  get ':id', to: 'traces#show', as: :trace, constraints: { id: /tr_[a-f0-9]+/ }
7
7
  end
@@ -16,6 +16,8 @@ module Dontbugme
16
16
  :capture_http_headers,
17
17
  :capture_http_body,
18
18
  :capture_redis_values,
19
+ :capture_redis_return_values,
20
+ :capture_span_output,
19
21
  :source_mode,
20
22
  :source_filter,
21
23
  :source_depth,
@@ -63,6 +65,8 @@ module Dontbugme
63
65
  self.capture_http_headers = []
64
66
  self.capture_http_body = false
65
67
  self.capture_redis_values = false
68
+ self.capture_redis_return_values = true
69
+ self.capture_span_output = true
66
70
  self.source_mode = :full
67
71
  self.source_filter = %w[app/ lib/]
68
72
  self.source_depth = 3
@@ -95,6 +99,8 @@ module Dontbugme
95
99
  self.recording_mode = :selective
96
100
  self.record_on_error = true
97
101
  self.capture_sql_binds = false
102
+ self.capture_redis_return_values = false
103
+ self.capture_span_output = false
98
104
  self.source_mode = :shallow
99
105
  self.source_depth = 1
100
106
  self.source_stack_limit = 30
@@ -134,13 +140,11 @@ module Dontbugme
134
140
  private
135
141
 
136
142
  def retention_seconds(value, unit)
137
- return value if value.is_a?(Integer)
138
- return value.to_i if defined?(ActiveSupport) && value.respond_to?(:to_i)
139
-
140
143
  case unit
141
- when :hours then value * 3600
142
- when :days then value * 86400
143
- else value
144
+ when nil, :seconds then value.to_i
145
+ when :hours then (value.to_f * 3600).to_i
146
+ when :days then (value.to_f * 86400).to_i
147
+ else value.to_i
144
148
  end
145
149
  end
146
150
  end
@@ -9,6 +9,7 @@ module Dontbugme
9
9
 
10
10
  def call(env)
11
11
  return @app.call(env) unless Dontbugme.config.recording?
12
+ return @app.call(env) unless Dontbugme.config.should_record_request?(env)
12
13
 
13
14
  request = ::Rack::Request.new(env)
14
15
  request_id = env['action_dispatch.request_id'] || request.get_header('HTTP_X_REQUEST_ID') || SecureRandom.uuid
@@ -7,6 +7,7 @@ module Dontbugme
7
7
  return yield unless Dontbugme.config.recording?
8
8
 
9
9
  job_class = job['class'] || job[:class] || 'Unknown'
10
+ return yield unless Dontbugme.config.should_record_job?(job_class)
10
11
  jid = job['jid'] || job[:jid] || SecureRandom.hex(8)
11
12
  correlation_id = job['correlation_id'] || job[:correlation_id] || Correlation.current
12
13
  Correlation.current = correlation_id
@@ -38,7 +38,7 @@ module Dontbugme
38
38
  end
39
39
 
40
40
  def self.from_h(hash)
41
- new(
41
+ span = new(
42
42
  category: hash[:category] || hash['category'],
43
43
  operation: hash[:operation] || hash['operation'],
44
44
  detail: hash[:detail] || hash['detail'],
@@ -47,6 +47,8 @@ module Dontbugme
47
47
  duration_ms: hash[:duration_ms] || hash['duration_ms'],
48
48
  source: hash[:source] || hash['source']
49
49
  )
50
+ span.instance_variable_set(:@id, (hash[:id] || hash['id']).to_s) if hash[:id] || hash['id']
51
+ span
50
52
  end
51
53
 
52
54
  private
@@ -6,10 +6,12 @@ module Dontbugme
6
6
  def initialize(backend)
7
7
  @backend = backend
8
8
  @queue = Queue.new
9
+ @pid = Process.pid
9
10
  @thread = start_worker
10
11
  end
11
12
 
12
13
  def save_trace(trace)
14
+ restart_worker_if_forked
13
15
  @queue << [:save, trace]
14
16
  end
15
17
 
@@ -22,11 +24,19 @@ module Dontbugme
22
24
  end
23
25
 
24
26
  def cleanup(before:)
27
+ restart_worker_if_forked
25
28
  @queue << [:cleanup, before]
26
29
  end
27
30
 
28
31
  private
29
32
 
33
+ def restart_worker_if_forked
34
+ return if Process.pid == @pid
35
+
36
+ @pid = Process.pid
37
+ @thread = start_worker
38
+ end
39
+
30
40
  def start_worker
31
41
  Thread.new do
32
42
  loop do
@@ -37,6 +47,8 @@ module Dontbugme
37
47
  when :cleanup
38
48
  @backend.cleanup(before: arg)
39
49
  end
50
+ rescue StandardError => e
51
+ warn "[Dontbugme] Async store error: #{e.class} #{e.message}"
40
52
  end
41
53
  end
42
54
  end
@@ -24,7 +24,7 @@ module Dontbugme
24
24
  def search(filters = {})
25
25
  traces = @mutex.synchronize { @traces.values.dup }
26
26
  traces = apply_filters(traces, filters)
27
- traces.sort_by { |t| t[:started_at] || '' }.reverse.map { |h| Trace.from_h(h) }
27
+ traces.map { |h| Trace.from_h(h) }
28
28
  end
29
29
 
30
30
  def cleanup(before:)
@@ -37,8 +37,8 @@ module Dontbugme
37
37
  private
38
38
 
39
39
  def apply_filters(traces, filters)
40
- traces = traces.select { |t| t[:status] == filters[:status].to_s } if filters[:status]
41
- traces = traces.select { |t| t[:status] == filters['status'].to_s } if filters['status']
40
+ traces = traces.select { |t| t[:status].to_s == filters[:status].to_s } if filters[:status]
41
+ traces = traces.select { |t| t[:status].to_s == filters['status'].to_s } if filters['status']
42
42
  traces = traces.select { |t| t[:kind].to_s == filters[:kind].to_s } if filters[:kind]
43
43
  if filters[:identifier]
44
44
  pattern = /#{Regexp.escape(filters[:identifier].to_s)}/i
@@ -48,8 +48,8 @@ module Dontbugme
48
48
  cid = filters[:correlation_id].to_s
49
49
  traces = traces.select { |t| (t[:correlation_id] || t.dig(:metadata, :correlation_id)).to_s == cid }
50
50
  end
51
- traces = traces.first(filters[:limit] || filters['limit'] || 100)
52
- traces
51
+ limit = filters[:limit] || filters['limit'] || 100
52
+ traces.sort_by { |t| t[:started_at] || '' }.reverse.first(limit)
53
53
  end
54
54
 
55
55
  def parse_time(val)
@@ -14,22 +14,7 @@ module Dontbugme
14
14
  def save_trace(trace)
15
15
  data = trace.to_h
16
16
  correlation_id = data[:correlation_id] || data[:metadata]&.dig(:correlation_id)
17
- exec_params(
18
- <<~SQL,
19
- INSERT INTO dontbugme_traces
20
- (id, kind, identifier, status, started_at, duration_ms, correlation_id, metadata_json, spans_json, error_json)
21
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
22
- ON CONFLICT (id) DO UPDATE SET
23
- kind = EXCLUDED.kind,
24
- identifier = EXCLUDED.identifier,
25
- status = EXCLUDED.status,
26
- started_at = EXCLUDED.started_at,
27
- duration_ms = EXCLUDED.duration_ms,
28
- correlation_id = EXCLUDED.correlation_id,
29
- metadata_json = EXCLUDED.metadata_json,
30
- spans_json = EXCLUDED.spans_json,
31
- error_json = EXCLUDED.error_json
32
- SQL
17
+ params = [
33
18
  data[:id],
34
19
  data[:kind].to_s,
35
20
  data[:identifier],
@@ -40,7 +25,22 @@ module Dontbugme
40
25
  data[:metadata].to_json,
41
26
  data[:spans].to_json,
42
27
  data[:error]&.to_json
43
- )
28
+ ]
29
+ exec_params(<<~SQL, params)
30
+ INSERT INTO dontbugme_traces
31
+ (id, kind, identifier, status, started_at, duration_ms, correlation_id, metadata_json, spans_json, error_json)
32
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
33
+ ON CONFLICT (id) DO UPDATE SET
34
+ kind = EXCLUDED.kind,
35
+ identifier = EXCLUDED.identifier,
36
+ status = EXCLUDED.status,
37
+ started_at = EXCLUDED.started_at,
38
+ duration_ms = EXCLUDED.duration_ms,
39
+ correlation_id = EXCLUDED.correlation_id,
40
+ metadata_json = EXCLUDED.metadata_json,
41
+ spans_json = EXCLUDED.spans_json,
42
+ error_json = EXCLUDED.error_json
43
+ SQL
44
44
  end
45
45
 
46
46
  def find_trace(trace_id)
@@ -92,6 +92,8 @@ module Dontbugme
92
92
  exec_params('DELETE FROM dontbugme_traces WHERE started_at < $1', [cutoff])
93
93
  end
94
94
 
95
+ private
96
+
95
97
  def query_result(sql, params)
96
98
  if conn.respond_to?(:exec_query)
97
99
  conn.exec_query(sql, 'Dontbugme', params)
@@ -106,8 +108,6 @@ module Dontbugme
106
108
  row
107
109
  end
108
110
 
109
- private
110
-
111
111
  def conn
112
112
  @connection
113
113
  end
@@ -159,8 +159,8 @@ module Dontbugme
159
159
  conn.execute('CREATE INDEX IF NOT EXISTS idx_dontbugme_started_at ON dontbugme_traces(started_at)')
160
160
  conn.execute('CREATE INDEX IF NOT EXISTS idx_dontbugme_status ON dontbugme_traces(status)')
161
161
  conn.execute('CREATE INDEX IF NOT EXISTS idx_dontbugme_correlation_id ON dontbugme_traces(correlation_id)')
162
- rescue StandardError
163
- # Schema might already exist
162
+ rescue StandardError => e
163
+ raise e if e.message !~ /already exists/i
164
164
  end
165
165
 
166
166
  def row_to_trace(row)
@@ -75,7 +75,10 @@ module Dontbugme
75
75
  def db
76
76
  @db ||= begin
77
77
  require 'sqlite3'
78
- SQLite3::Database.new(@path)
78
+ db = SQLite3::Database.new(@path)
79
+ db.execute('PRAGMA journal_mode=WAL')
80
+ db.execute('PRAGMA busy_timeout=5000')
81
+ db
79
82
  end
80
83
  end
81
84
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Dontbugme
4
4
  module Subscribers
5
- class ActionMailer
5
+ class ActionMailer < Base
6
6
  EVENT = 'deliver.action_mailer'
7
7
 
8
8
  def self.subscribe
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Dontbugme
4
4
  module Subscribers
5
- class ActiveJob
5
+ class ActiveJob < Base
6
6
  EVENT = 'enqueue.active_job'
7
7
 
8
8
  def self.subscribe
@@ -41,6 +41,10 @@ module Dontbugme
41
41
  }
42
42
  payload[:error] = error.message if error
43
43
 
44
+ if config.capture_http_body && response&.body
45
+ payload[:response_body] = truncate(response.body, config.max_http_body_size)
46
+ end
47
+
44
48
  if config.capture_http_headers&.any?
45
49
  payload[:request_headers] = capture_headers(req, config.capture_http_headers)
46
50
  end
@@ -20,7 +20,7 @@ module Dontbugme
20
20
  result = super
21
21
  duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start_time).round(2)
22
22
 
23
- record_span(command, start_wall, duration_ms)
23
+ record_span(command, start_wall, duration_ms, result: result)
24
24
  result
25
25
  rescue StandardError => e
26
26
  duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start_time).round(2)
@@ -30,7 +30,7 @@ module Dontbugme
30
30
 
31
31
  private
32
32
 
33
- def record_span(command, start_wall, duration_ms, error: nil)
33
+ def record_span(command, start_wall, duration_ms, result: nil, error: nil)
34
34
  cmd = Array(command).map(&:to_s)
35
35
  operation = cmd.first&.upcase || 'UNKNOWN'
36
36
  detail = cmd.join(' ')
@@ -40,6 +40,9 @@ module Dontbugme
40
40
  if config.capture_redis_values && cmd.size > 1
41
41
  payload[:args] = cmd[1..].map { |a| truncate(a, config.max_redis_value_size) }
42
42
  end
43
+ if config.capture_redis_return_values && result && !error
44
+ payload[:output] = truncate_value(result, config.max_redis_value_size)
45
+ end
43
46
  payload[:error] = error.message if error
44
47
 
45
48
  Dontbugme::Recorder.add_span(
@@ -57,6 +60,13 @@ module Dontbugme
57
60
 
58
61
  "#{str.to_s.byteslice(0, max)}[truncated]"
59
62
  end
63
+
64
+ def truncate_value(val, max)
65
+ str = val.to_s
66
+ return str if str.bytesize <= max
67
+
68
+ "#{str.byteslice(0, max)}[truncated]"
69
+ end
60
70
  end
61
71
  end
62
72
  end
@@ -43,7 +43,7 @@ module Dontbugme
43
43
  end
44
44
 
45
45
  def raw_spans
46
- @spans.freeze
46
+ @spans.dup
47
47
  end
48
48
 
49
49
  def finish!(error: nil)
@@ -74,12 +74,13 @@ module Dontbugme
74
74
  end
75
75
 
76
76
  def to_h
77
+ finished_at_time = @finished_at ? (@started_at_utc + (duration_ms || 0) / 1000.0) : nil
77
78
  {
78
79
  id: id,
79
80
  kind: kind,
80
81
  identifier: identifier,
81
82
  started_at: format_time(started_at_utc),
82
- finished_at: @finished_at ? format_time(Time.at(@finished_at / 1000.0).utc) : nil,
83
+ finished_at: finished_at_time ? format_time(finished_at_time) : nil,
83
84
  duration_ms: duration_ms,
84
85
  status: status,
85
86
  error: error,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dontbugme
4
- VERSION = '0.1.3'
4
+ VERSION = '0.1.5'
5
5
  end
data/lib/dontbugme.rb CHANGED
@@ -24,7 +24,7 @@ module Dontbugme
24
24
  Recorder.record(kind: :custom, identifier: identifier, metadata: metadata, return_trace: true, &block)
25
25
  end
26
26
 
27
- def span(name, payload: {}, &block)
27
+ def span(name, payload: {}, capture_output: true, &block)
28
28
  return yield unless Context.active?
29
29
 
30
30
  start_mono = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
@@ -32,11 +32,16 @@ module Dontbugme
32
32
  result = yield
33
33
  duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start_mono).round(2)
34
34
 
35
+ span_payload = payload.dup
36
+ if capture_output && config.capture_span_output
37
+ span_payload[:output] = format_output_value(result)
38
+ end
39
+
35
40
  Recorder.add_span(
36
41
  category: :custom,
37
42
  operation: 'span',
38
43
  detail: name.to_s,
39
- payload: payload,
44
+ payload: span_payload,
40
45
  duration_ms: duration_ms,
41
46
  started_at: start_wall
42
47
  )
@@ -65,6 +70,52 @@ module Dontbugme
65
70
 
66
71
  private
67
72
 
73
+ def format_output_value(val)
74
+ return nil if val.nil?
75
+
76
+ max = config.max_span_detail_size
77
+ str = if val.is_a?(Array)
78
+ format_array(val)
79
+ elsif val.is_a?(Hash)
80
+ format_hash(val)
81
+ elsif defined?(ActiveRecord::Base) && val.is_a?(ActiveRecord::Base)
82
+ val.inspect
83
+ elsif defined?(ActiveRecord::Relation) && val.is_a?(ActiveRecord::Relation)
84
+ "#{val.to_sql} (relation)"
85
+ else
86
+ val.to_s
87
+ end
88
+ str.bytesize > max ? "#{str.byteslice(0, max)}...[truncated]" : str
89
+ rescue StandardError
90
+ val.class.name
91
+ end
92
+
93
+ def format_array(ary)
94
+ return '[]' if ary.empty?
95
+
96
+ preview = ary.first(5).map { |v| format_single_value(v) }.join(', ')
97
+ ary.size > 5 ? "[#{preview}, ... (#{ary.size} total)]" : "[#{preview}]"
98
+ end
99
+
100
+ def format_hash(hash)
101
+ return '{}' if hash.empty?
102
+
103
+ preview = hash.first(5).map { |k, v| "#{k}: #{format_single_value(v)}" }.join(', ')
104
+ hash.size > 5 ? "{#{preview}, ...}" : "{#{preview}}"
105
+ end
106
+
107
+ def format_single_value(v)
108
+ if defined?(ActiveRecord::Base) && v.is_a?(ActiveRecord::Base)
109
+ v.respond_to?(:id) ? "#<#{v.class.name} id=#{v.id}>" : "#<#{v.class.name}>"
110
+ elsif v.is_a?(Hash)
111
+ '{...}'
112
+ elsif v.is_a?(Array)
113
+ '[...]'
114
+ else
115
+ v.to_s
116
+ end
117
+ end
118
+
68
119
  def build_store
69
120
  store = case config.store
70
121
  when :sqlite
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dontbugme
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Inspector Contributors
@@ -122,15 +122,16 @@ files:
122
122
  - LICENSE
123
123
  - README.md
124
124
  - app/controllers/dontbugme/traces_controller.rb
125
+ - app/helpers/dontbugme/traces_helper.rb
125
126
  - app/views/dontbugme/traces/diff.html.erb
126
127
  - app/views/dontbugme/traces/index.html.erb
127
128
  - app/views/dontbugme/traces/show.html.erb
128
129
  - app/views/layouts/dontbugme/application.html.erb
129
130
  - bin/dontbugme
131
+ - config/routes.rb
130
132
  - lib/dontbugme.rb
131
133
  - lib/dontbugme/cleanup_job.rb
132
134
  - lib/dontbugme/cli.rb
133
- - lib/dontbugme/config/routes.rb
134
135
  - lib/dontbugme/configuration.rb
135
136
  - lib/dontbugme/context.rb
136
137
  - lib/dontbugme/correlation.rb