dontbugme 0.1.4 → 0.1.6

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: f1bab13c641c7fb30659e5ed19ef54331355ae7239d722a585e094115b47a0a9
4
- data.tar.gz: ab863ecd3c380117ae77c731b9a917795c0fa4bc89c0a6cbe2f0d780b5597b59
3
+ metadata.gz: 6f71bd0ef418ea0d47ca94a5242d3677a00d82cf36e2fd0d8b69f3f4ce323f9c
4
+ data.tar.gz: c9692d06855f24fc322e218588958881a0a8d9ee4b278125961143b7527fdf3a
5
5
  SHA512:
6
- metadata.gz: a1b4ee5fdbd1a36898a712ce07ceca1016b487d9362a61745116172250eb9cecb905919d1b327f4c2ae4e3a6e0c66cf4c97e53b4f91e8247de658cc9621e5059
7
- data.tar.gz: e033607a2dad279edd380e60ab2633fa940a8d04189bcb2c7b0289e99bf599b2d6ecada08b590b39f137717179d03576f289e6c3e375fda634eab84b36a75878
6
+ metadata.gz: 85e2c35aa316839754b10c22353ef1b89970c893f78f537708fce9a5af7653c4532219a98bd035c833bfdc9e3f3d4fbd3c61a6ad8fb9f60a6d8bff2f1a301511
7
+ data.tar.gz: 646af28b63c0f177fcc68b48c0892f3a92c0b1dd102aad33b70d2c54915b85fa325bdcf5b6661c4e2b8c5b13af735d10a6b088643df9335f8967ab8569e918b9
data/README.md CHANGED
@@ -114,18 +114,40 @@ 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
+
131
+ ### Automatic Variable Tracking
132
+
133
+ Dontbugme automatically captures local variable changes between lines in your app code. No manual instrumentation needed — when a variable like `token` changes from `abc123` to `abc124`, you'll see an observe span with Input and Output in the UI.
134
+
135
+ ```ruby
136
+ token = Member.find(1).confirmation_token
137
+ token += 1
138
+ Member.find(1).update(confirmation_token: token)
139
+ ```
140
+
141
+ The UI will show both **Input** and **Output** for the transformation. Enabled by default in development; disable with `config.capture_variable_changes = false`. Only tracks simple types (String, Integer, Float, etc.) to avoid noise.
142
+
143
+ ### Manual Observe (optional)
144
+
145
+ For explicit control, use `Dontbugme.observe`:
146
+
147
+ ```ruby
148
+ token = Dontbugme.observe('token increment', token) { token + 1 }
149
+ ```
150
+
129
151
  ### Span Categories
130
152
 
131
153
  Access spans by category for assertions or analysis:
@@ -185,6 +207,11 @@ Dontbugme.configure do |config|
185
207
  config.recording_mode = :always
186
208
  config.capture_sql_binds = true
187
209
  config.source_mode = :full
210
+
211
+ # Capture outputs for debugging (development only)
212
+ config.capture_span_output = true # return values from Dontbugme.span
213
+ config.capture_http_body = true # HTTP response bodies
214
+ config.capture_redis_return_values = true # Redis command return values
188
215
  end
189
216
  ```
190
217
 
@@ -0,0 +1,96 @@
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
+ DEDICATED_INPUT_KEYS = %w[input].freeze
21
+
22
+ def span_input(span)
23
+ return nil if span.payload.blank?
24
+
25
+ payload = span.payload.is_a?(Hash) ? span.payload : {}
26
+ key = DEDICATED_INPUT_KEYS.find { |k| payload[k.to_sym] || payload[k] }
27
+ key ? (payload[key.to_sym] || payload[key]) : nil
28
+ end
29
+
30
+ def span_output(span)
31
+ return nil if span.payload.blank?
32
+
33
+ payload = span.payload.is_a?(Hash) ? span.payload : {}
34
+ key = OUTPUT_KEYS.find { |k| payload[k.to_sym] || payload[k] }
35
+ key ? (payload[key.to_sym] || payload[key]) : nil
36
+ end
37
+
38
+ def format_span_payload(span)
39
+ return [] if span.payload.blank?
40
+
41
+ payload = span.payload.is_a?(Hash) ? span.payload : {}
42
+ payload.map do |key, value|
43
+ next if value.nil?
44
+ next if OUTPUT_KEYS.include?(key.to_s)
45
+ next if DEDICATED_INPUT_KEYS.include?(key.to_s)
46
+
47
+ display_value = case value
48
+ when Array then value.map { |v| v.is_a?(String) ? v : v.inspect }.join(', ')
49
+ when Hash then value.inspect
50
+ else value.to_s
51
+ end
52
+ display_value = "#{display_value[0, 500]}..." if display_value.length > 500
53
+ [key.to_s.tr('_', ' ').capitalize, display_value]
54
+ end.compact
55
+ end
56
+
57
+ def format_span_detail_for_display(span)
58
+ span.detail
59
+ end
60
+
61
+ def truncate_detail(str, max_len = 80)
62
+ return '' if str.blank?
63
+ str = str.to_s.strip
64
+ return str if str.length <= max_len
65
+ "#{str[0, max_len - 3]}..."
66
+ end
67
+
68
+ def trace_started_at_formatted(trace)
69
+ return nil unless trace.respond_to?(:started_at_utc) && trace.started_at_utc
70
+
71
+ trace.started_at_utc.respond_to?(:strftime) ? trace.started_at_utc.strftime('%Y-%m-%d %H:%M:%S.%3N UTC') : trace.started_at_utc.to_s
72
+ end
73
+
74
+ def trace_started_at_short(trace)
75
+ return nil unless trace.respond_to?(:started_at_utc) && trace.started_at_utc
76
+
77
+ trace.started_at_utc.respond_to?(:strftime) ? trace.started_at_utc.strftime('%Y-%m-%d %H:%M:%S') : trace.started_at_utc.to_s
78
+ end
79
+
80
+ def trace_finished_at_formatted(trace)
81
+ return nil unless trace.respond_to?(:duration_ms) && trace.duration_ms
82
+
83
+ finished = trace.started_at_utc + (trace.duration_ms / 1000.0)
84
+ finished.respond_to?(:strftime) ? finished.strftime('%Y-%m-%d %H:%M:%S.%3N UTC') : finished.to_s
85
+ end
86
+
87
+ def span_timestamp_formatted(trace, span)
88
+ return nil unless trace.respond_to?(:started_at_utc) && trace.started_at_utc
89
+ return nil unless span.respond_to?(:started_at) && span.started_at
90
+
91
+ offset_sec = (span.started_at.to_f / 1000.0)
92
+ at = trace.started_at_utc + offset_sec
93
+ at.respond_to?(:strftime) ? at.strftime('%H:%M:%S.%3N') : at.to_s
94
+ end
95
+ end
96
+ 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,16 @@
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
+ <span class="trace-timestamp" title="<%= trace_started_at_formatted(trace) %>"><%= trace_started_at_short(trace) %></span>
26
+ <% end %>
26
27
  <% end %>
27
28
  <% else %>
28
29
  <p class="empty">No traces found. Run your app and execute some jobs or requests.</p>
@@ -1,15 +1,104 @@
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-timestamp" title="<%= trace_started_at_formatted(@trace) %>"><%= trace_started_at_formatted(@trace) %></span>
8
+ <span class="trace-meta-item"><%= @trace.id %></span>
9
+ <% if @trace.correlation_id.present? %>
10
+ <%= link_to "Follow chain", root_path(correlation_id: @trace.correlation_id), class: 'trace-link' %>
11
+ <% end %>
12
+ </div>
13
+ </div>
14
+ <div class="trace-actions">
15
+ <%= link_to '← Back to traces', root_path, class: 'btn btn-secondary' %>
16
+ <%= link_to 'Compare with another', diff_path(a: @trace.id), class: 'btn btn-secondary' %>
17
+ </div>
18
+ </div>
19
+
20
+ <% if params[:only].present? %>
21
+ <p class="filter-hint">Filtering by: <strong><%= params[:only] %></strong> · <%= link_to 'Show all', trace_path(@trace.id) %></p>
22
+ <% end %>
23
+
24
+ <div class="timeline-viz">
25
+ <div class="timeline-bar" style="--total-ms: <%= [@trace.duration_ms || 1, 1].max %>;">
26
+ <% @trace.raw_spans.each do |span| %>
27
+ <% next if params[:only].present? && span.category.to_s != params[:only] %>
28
+ <% width = span.duration_ms.to_f > 0 ? [(span.duration_ms / (@trace.duration_ms || 1) * 100).round(1), 2].max : 2 %>
29
+ <span class="timeline-segment badge-<%= span.category %>" style="width: <%= width %>%;" title="<%= span.category %> <%= span.duration_ms.round(1) %>ms"></span>
7
30
  <% end %>
8
- </p>
9
- <div class="timeline"><%= Dontbugme::Formatters::Timeline.format(@trace) %></div>
31
+ </div>
32
+ </div>
33
+
34
+ <div class="spans-list">
35
+ <% spans_to_show = params[:only].present? ? @trace.raw_spans.select { |s| s.category.to_s == params[:only] } : @trace.raw_spans %>
36
+ <% spans_to_show.each_with_index do |span, idx| %>
37
+ <details class="span-card card" data-category="<%= span.category %>">
38
+ <summary class="span-summary">
39
+ <span class="span-timestamp" title="<%= trace_started_at_formatted(@trace) %>"><%= span_timestamp_formatted(@trace, span) %></span>
40
+ <span class="span-offset"><%= span.started_at.to_f.round(1) %>ms</span>
41
+ <span class="span-duration"><%= span.duration_ms ? "#{span.duration_ms.round(1)}ms" : '-' %></span>
42
+ <span class="badge <%= span_category_badge_class(span.category) %>"><%= span.category.to_s.upcase %></span>
43
+ <span class="span-operation"><%= span.operation %></span>
44
+ <span class="span-detail-preview"><%= truncate_detail(span.detail, 80) %></span>
45
+ </summary>
46
+ <div class="span-details">
47
+ <% if span_input(span).present? %>
48
+ <div class="span-section span-input-section">
49
+ <h4>Input <button type="button" class="copy-btn" data-copy-target="input-<%= idx %>" title="Copy">Copy</button></h4>
50
+ <pre class="code-block input-block" id="input-<%= idx %>"><code><%= h(span_input(span)) %></code></pre>
51
+ </div>
52
+ <% end %>
53
+ <% if span_output(span).present? %>
54
+ <div class="span-section span-output-section">
55
+ <h4>Output <button type="button" class="copy-btn" data-copy-target="output-<%= idx %>" title="Copy">Copy</button></h4>
56
+ <pre class="code-block output-block" id="output-<%= idx %>"><code><%= h(span_output(span)) %></code></pre>
57
+ </div>
58
+ <% end %>
59
+ <div class="span-section">
60
+ <h4>Detail <button type="button" class="copy-btn" data-copy-target="detail-<%= idx %>" title="Copy">Copy</button></h4>
61
+ <pre class="code-block" id="detail-<%= idx %>"><code><%= h(format_span_detail_for_display(span)) %></code></pre>
62
+ </div>
63
+ <% if format_span_payload(span).any? %>
64
+ <div class="span-section">
65
+ <h4>Inputs</h4>
66
+ <dl class="payload-list">
67
+ <% format_span_payload(span).each do |key, value| %>
68
+ <div class="payload-row">
69
+ <dt><%= key %></dt>
70
+ <dd><pre class="payload-value"><%= h(value) %></pre></dd>
71
+ </div>
72
+ <% end %>
73
+ </dl>
74
+ </div>
75
+ <% end %>
76
+ <% if span.source.present? %>
77
+ <div class="span-section">
78
+ <h4>Source</h4>
79
+ <code class="source-location"><%= h(span.source) %></code>
80
+ </div>
81
+ <% end %>
82
+ </div>
83
+ </details>
84
+ <% end %>
10
85
  </div>
11
86
 
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>
87
+ <% if @trace.truncated_spans_count.to_i.positive? %>
88
+ <p class="truncated-notice">+ <%= @trace.truncated_spans_count %> additional spans truncated</p>
89
+ <% end %>
90
+
91
+ <div class="trace-footer">
92
+ <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') %> · <%= link_to 'Custom', trace_path(@trace.id, only: 'custom') %> · <%= link_to 'Snapshot', trace_path(@trace.id, only: 'snapshot') %></p>
93
+ </div>
94
+
95
+ <% if @trace.error.present? %>
96
+ <div class="card error-card">
97
+ <h3>Error</h3>
98
+ <pre class="code-block error-detail"><%= h(@trace.error[:message] || @trace.error['message']) %></pre>
99
+ <% bt = @trace.error[:backtrace] || @trace.error['backtrace'] %>
100
+ <% if bt.present? %>
101
+ <pre class="code-block backtrace"><%= Array(bt).map { |l| h(l) }.join("\n") %></pre>
102
+ <% end %>
103
+ </div>
104
+ <% end %>
@@ -7,38 +7,109 @@
7
7
  <title>Dontbugme</title>
8
8
  <style>
9
9
  * { box-sizing: border-box; }
10
- 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; }
11
- .container { max-width: 1000px; margin: 0 auto; padding: 24px; }
12
- header { background: #fff; border-bottom: 1px solid #e5e7eb; padding: 16px 24px; margin-bottom: 24px; }
13
- 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; }
14
14
  header nav { margin-top: 12px; }
15
- header a { color: #4b5563; text-decoration: none; margin-right: 16px; }
16
- header a:hover { color: #111; }
17
- header a.active { color: #111; font-weight: 600; }
18
- .card { background: #fff; border-radius: 6px; border: 1px solid #e5e7eb; padding: 16px; margin-bottom: 16px; }
19
- .trace-row { display: flex; align-items: center; gap: 16px; padding: 0 0 12px; border-bottom: 1px solid #f3f4f6; }
20
- .trace-row:last-child { border-bottom: none; padding-bottom: 0; }
21
- .trace-row:hover { background: #f9fafb; margin: 0 -16px; padding: 0 16px 12px; }
22
- .trace-id { font-family: monospace; font-size: 12px; color: #6b7280; min-width: 140px; }
23
- .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; }
24
24
  .trace-id a:hover { text-decoration: underline; }
25
- .trace-identifier { flex: 1; }
26
- .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; }
27
- .badge-success { background: #d1fae5; color: #065f46; }
28
- .badge-error { background: #fee2e2; color: #991b1b; }
29
- .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; }
39
+ .trace-timestamp { font-family: monospace; font-size: 12px; color: #64748b; }
40
+ .span-timestamp { font-family: monospace; font-size: 11px; color: #64748b; min-width: 90px; }
30
41
  .filters { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
31
- .filters input, .filters select { padding: 8px 12px; border: 1px solid #e5e7eb; border-radius: 4px; font-size: 13px; }
32
- .filters button { padding: 8px 16px; background: #111; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; }
33
- .filters button:hover { background: #333; }
34
- .timeline { font-size: 12px; white-space: pre-wrap; font-family: ui-monospace, monospace; }
35
- .span-line { padding: 4px 0; }
36
- .span-source { color: #6b7280; padding-left: 12px; font-size: 11px; }
42
+ .filters input, .filters select { padding: 10px 14px; border: 1px solid #334155; border-radius: 6px; font-size: 13px; background: #1e293b; color: #e2e8f0; }
43
+ .filters button { padding: 10px 20px; background: #38bdf8; color: #0f172a; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 600; }
44
+ .filters button:hover { background: #7dd3fc; }
37
45
  .diff-form { display: flex; gap: 12px; align-items: center; margin-bottom: 24px; }
38
- .diff-form input { flex: 1; padding: 8px 12px; border: 1px solid #e5e7eb; border-radius: 4px; }
39
- .diff-output { white-space: pre-wrap; font-size: 12px; background: #1f2937; color: #e5e7eb; padding: 16px; border-radius: 6px; overflow-x: auto; }
40
- .empty { color: #6b7280; padding: 24px; text-align: center; }
46
+ .diff-form input { flex: 1; padding: 10px 14px; border: 1px solid #334155; border-radius: 6px; background: #1e293b; color: #e2e8f0; }
47
+ .diff-output { white-space: pre-wrap; font-size: 12px; background: #0f172a; color: #e2e8f0; padding: 20px; border-radius: 8px; overflow-x: auto; font-family: monospace; }
48
+ .empty { color: #64748b; padding: 24px; text-align: center; }
49
+ .trace-header { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; gap: 16px; }
50
+ .trace-title { margin: 0 0 8px; font-size: 18px; font-weight: 600; color: #f8fafc; }
51
+ .trace-meta { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
52
+ .trace-meta-item { color: #64748b; font-size: 13px; }
53
+ .trace-link { color: #38bdf8; text-decoration: none; }
54
+ .trace-link:hover { text-decoration: underline; }
55
+ .trace-actions { display: flex; gap: 8px; }
56
+ .btn { padding: 8px 16px; border-radius: 6px; font-size: 13px; text-decoration: none; font-weight: 500; }
57
+ .btn-secondary { background: #334155; color: #e2e8f0; }
58
+ .btn-secondary:hover { background: #475569; }
59
+ .timeline-viz { margin-bottom: 24px; }
60
+ .timeline-bar { display: flex; height: 24px; border-radius: 6px; overflow: hidden; background: #334155; }
61
+ .timeline-segment { min-width: 2px; transition: opacity 0.2s; }
62
+ .timeline-segment:hover { opacity: 0.8; }
63
+ .spans-list { display: flex; flex-direction: column; gap: 8px; }
64
+ .span-card { padding: 0; overflow: hidden; }
65
+ .span-card summary { padding: 14px 20px; cursor: pointer; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; list-style: none; }
66
+ .span-card summary::-webkit-details-marker { display: none; }
67
+ .span-card summary::after { content: '▶'; color: #64748b; font-size: 10px; margin-left: auto; transition: transform 0.2s; }
68
+ .span-card[open] summary::after { transform: rotate(90deg); }
69
+ .span-card summary:hover { background: #334155; }
70
+ .span-offset { font-family: monospace; font-size: 12px; color: #64748b; min-width: 50px; }
71
+ .span-duration { font-family: monospace; font-size: 12px; color: #38bdf8; min-width: 55px; }
72
+ .span-operation { color: #94a3b8; font-size: 12px; }
73
+ .span-detail-preview { color: #cbd5e1; font-size: 13px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
74
+ .span-details { padding: 0 20px 20px; border-top: 1px solid #334155; }
75
+ .span-section { margin-top: 16px; }
76
+ .span-section h4 { margin: 0 0 8px; font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
77
+ .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; }
78
+ .payload-list { margin: 0; }
79
+ .payload-row { display: grid; grid-template-columns: 140px 1fr; gap: 12px; margin-bottom: 12px; align-items: start; }
80
+ .payload-row dt { margin: 0; color: #64748b; font-size: 12px; font-weight: 500; }
81
+ .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; }
82
+ .source-location { display: block; padding: 8px 12px; background: #0f172a; border-radius: 4px; font-size: 11px; color: #94a3b8; }
83
+ .filter-hint { color: #94a3b8; margin-bottom: 16px; font-size: 13px; }
84
+ .filter-hint a { color: #38bdf8; }
85
+ .trace-footer { margin-top: 24px; }
86
+ .trace-footer p { color: #64748b; font-size: 13px; }
87
+ .trace-footer a { color: #38bdf8; }
88
+ .truncated-notice { color: #64748b; font-size: 13px; margin-top: 8px; }
89
+ .error-card { border-color: #7f1d1d; }
90
+ .error-card h3 { color: #f87171; margin: 0 0 12px; }
91
+ .error-detail { color: #f87171; }
92
+ .backtrace { font-size: 11px; color: #94a3b8; }
93
+ .span-section h4 { display: flex; align-items: center; gap: 8px; }
94
+ .span-output-section .output-block { border-left: 3px solid #38bdf8; }
95
+ .span-input-section .input-block { border-left: 3px solid #94a3b8; }
96
+ .copy-btn { background: #334155; color: #94a3b8; border: none; padding: 4px 10px; border-radius: 4px; font-size: 11px; cursor: pointer; }
97
+ .copy-btn:hover { background: #475569; color: #e2e8f0; }
41
98
  </style>
99
+ <script>
100
+ document.querySelectorAll('.copy-btn').forEach(function(btn) {
101
+ btn.addEventListener('click', function() {
102
+ var target = document.getElementById(this.dataset.copyTarget);
103
+ if (!target) return;
104
+ var text = target.textContent;
105
+ navigator.clipboard.writeText(text).then(function() {
106
+ var orig = btn.textContent;
107
+ btn.textContent = 'Copied!';
108
+ setTimeout(function() { btn.textContent = orig; }, 1500);
109
+ });
110
+ });
111
+ });
112
+ </script>
42
113
  </head>
43
114
  <body>
44
115
  <header>
@@ -50,7 +121,7 @@
50
121
  </nav>
51
122
  </div>
52
123
  </header>
53
- <main class="container">
124
+ <main class="container" style="color: #e2e8f0;">
54
125
  <%= yield %>
55
126
  </main>
56
127
  </body>
@@ -16,6 +16,9 @@ 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,
21
+ :capture_variable_changes,
19
22
  :source_mode,
20
23
  :source_filter,
21
24
  :source_depth,
@@ -63,6 +66,9 @@ module Dontbugme
63
66
  self.capture_http_headers = []
64
67
  self.capture_http_body = false
65
68
  self.capture_redis_values = false
69
+ self.capture_redis_return_values = true
70
+ self.capture_span_output = true
71
+ self.capture_variable_changes = true
66
72
  self.source_mode = :full
67
73
  self.source_filter = %w[app/ lib/]
68
74
  self.source_depth = 3
@@ -85,6 +91,7 @@ module Dontbugme
85
91
  self.recording_mode = :off
86
92
  self.enable_web_ui = false
87
93
  self.record_on_error = false
94
+ self.capture_variable_changes = false
88
95
  self.max_trace_buffer_bytes = 5 * 1024 * 1024 # 5 MB
89
96
  end
90
97
 
@@ -95,6 +102,9 @@ module Dontbugme
95
102
  self.recording_mode = :selective
96
103
  self.record_on_error = true
97
104
  self.capture_sql_binds = false
105
+ self.capture_redis_return_values = false
106
+ self.capture_span_output = false
107
+ self.capture_variable_changes = false
98
108
  self.source_mode = :shallow
99
109
  self.source_depth = 1
100
110
  self.source_stack_limit = 30
@@ -12,12 +12,15 @@ module Dontbugme
12
12
  return @app.call(env) unless Dontbugme.config.should_record_request?(env)
13
13
 
14
14
  request = ::Rack::Request.new(env)
15
+ path = request.path.to_s
16
+ mount_path = Dontbugme.config.web_ui_mount_path.to_s.chomp('/')
17
+ return @app.call(env) if mount_path.to_s != '' && (path == mount_path || path.start_with?("#{mount_path}/"))
18
+
15
19
  request_id = env['action_dispatch.request_id'] || request.get_header('HTTP_X_REQUEST_ID') || SecureRandom.uuid
16
20
  correlation_id = env['HTTP_X_CORRELATION_ID'] || Correlation.generate
17
21
  Correlation.current = correlation_id
18
22
 
19
23
  method = request.request_method
20
- path = request.path
21
24
  identifier = "#{method} #{path}"
22
25
 
23
26
  metadata = {
@@ -23,6 +23,7 @@ module Dontbugme
23
23
  Dontbugme::Subscribers::Cache.subscribe
24
24
  Dontbugme::Subscribers::ActionMailer.subscribe
25
25
  Dontbugme::Subscribers::ActiveJob.subscribe
26
+ Dontbugme::VariableTracker.subscribe
26
27
  end
27
28
 
28
29
  config.after_initialize do
@@ -13,6 +13,7 @@ module Dontbugme
13
13
 
14
14
  trace = Trace.new(kind: kind, identifier: identifier, metadata: metadata)
15
15
  Context.current = trace
16
+ VariableTracker.clear_state! if defined?(VariableTracker)
16
17
 
17
18
  result = yield
18
19
  trace.finish!
@@ -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
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dontbugme
4
+ # Automatically captures local variable changes between lines using TracePoint.
5
+ # Emits observe-style spans when variables change, so you can inspect value
6
+ # transformations without manual Dontbugme.observe calls.
7
+ class VariableTracker
8
+ THREAD_KEY = :dontbugme_variable_tracker_state
9
+ THREAD_PATH_KEY = :dontbugme_variable_tracker_path
10
+ IN_TRACKER_KEY = :dontbugme_variable_tracker_in_callback
11
+ SKIP_VARS = %w[_ result trace e ex].freeze
12
+ TRACKABLE_CLASSES = [String, Integer, Float, Symbol, TrueClass, FalseClass, NilClass].freeze
13
+
14
+ class << self
15
+ def subscribe
16
+ return if @subscribed
17
+
18
+ @trace_point = TracePoint.new(:line) { |tp| handle_line(tp) }
19
+ @trace_point.enable
20
+ @subscribed = true
21
+ end
22
+
23
+ def unsubscribe
24
+ return unless @subscribed
25
+
26
+ @trace_point&.disable
27
+ @trace_point = nil
28
+ @subscribed = false
29
+ end
30
+
31
+ def handle_line(tp)
32
+ return if Thread.current[IN_TRACKER_KEY]
33
+ return unless Dontbugme.config.recording?
34
+ return unless Dontbugme.config.capture_variable_changes
35
+ return unless Context.active?
36
+
37
+ path = tp.path.to_s
38
+ return if path.include?('dontbugme') || path.include?('/gems/') || path.include?('bundler')
39
+ return unless Dontbugme.config.source_filter.any? { |f| path.include?(f) }
40
+
41
+ binding = tp.binding
42
+ return unless binding
43
+
44
+ Thread.current[IN_TRACKER_KEY] = true
45
+ begin
46
+ current = extract_locals(binding)
47
+ prev = Thread.current[THREAD_KEY]
48
+ prev_path = Thread.current[THREAD_PATH_KEY]
49
+ # Only diff when we're in the same file (avoid cross-scope false positives)
50
+ if prev && prev_path == path
51
+ diff_and_emit(prev, current, tp)
52
+ end
53
+ Thread.current[THREAD_KEY] = current
54
+ Thread.current[THREAD_PATH_KEY] = path
55
+ ensure
56
+ Thread.current[IN_TRACKER_KEY] = false
57
+ end
58
+ end
59
+
60
+ def clear_state!
61
+ Thread.current[THREAD_KEY] = nil
62
+ Thread.current[THREAD_PATH_KEY] = nil
63
+ end
64
+
65
+ private
66
+
67
+ def extract_locals(binding)
68
+ return {} unless binding.respond_to?(:local_variables)
69
+
70
+ binding.local_variables.each_with_object({}) do |name, h|
71
+ next if SKIP_VARS.include?(name.to_s)
72
+ next if name.to_s.start_with?('_')
73
+
74
+ begin
75
+ h[name] = binding.local_variable_get(name)
76
+ rescue StandardError
77
+ # Some vars (e.g. from C extensions) may not be readable
78
+ end
79
+ end
80
+ end
81
+
82
+ def diff_and_emit(prev, current, tp)
83
+ changed = current.select do |name, new_val|
84
+ prev_val = prev[name]
85
+ !values_equal?(prev_val, new_val)
86
+ end
87
+ return if changed.empty?
88
+
89
+ changed.each do |name, new_val|
90
+ prev_val = prev[name]
91
+ emit_observe(name, prev_val, new_val, tp)
92
+ end
93
+ end
94
+
95
+ def values_equal?(a, b)
96
+ return true if a.equal?(b)
97
+ return a == b if a.nil? || b.nil?
98
+
99
+ a == b
100
+ rescue StandardError
101
+ false
102
+ end
103
+
104
+ def emit_observe(name, input, output, tp)
105
+ return unless (input.nil? || trackable_value?(input)) && (output.nil? || trackable_value?(output))
106
+
107
+ detail = "#{name} changed"
108
+ payload = {
109
+ input: format_value(input),
110
+ output: format_value(output)
111
+ }
112
+ Recorder.add_span(
113
+ category: :custom,
114
+ operation: 'observe',
115
+ detail: detail,
116
+ payload: payload,
117
+ duration_ms: 0,
118
+ started_at: Time.now
119
+ )
120
+ end
121
+
122
+ def format_value(val)
123
+ return nil if val.nil?
124
+
125
+ Dontbugme.send(:format_output_value, val)
126
+ rescue StandardError
127
+ val.class.name
128
+ end
129
+
130
+ def trackable_value?(val)
131
+ return true if val.nil?
132
+ return true if TRACKABLE_CLASSES.any? { |c| val.is_a?(c) }
133
+ return true if val.is_a?(Array) && val.size <= 10 && val.all? { |v| trackable_value?(v) }
134
+ return true if val.is_a?(Hash) && val.size <= 10 && val.values.all? { |v| trackable_value?(v) }
135
+
136
+ false
137
+ end
138
+ end
139
+ end
140
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dontbugme
4
- VERSION = '0.1.4'
4
+ VERSION = '0.1.6'
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,17 +32,32 @@ 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
  )
43
48
  result
44
49
  end
45
50
 
51
+ # Captures input and output of in-house calculations for value transformation inspection.
52
+ # Example: token = Dontbugme.observe('token increment', token) { token + 1 }
53
+ def observe(name, input = nil, &block)
54
+ return yield unless Context.active?
55
+
56
+ payload = {}
57
+ payload[:input] = format_output_value(input) unless input.nil?
58
+ span(name, payload: payload, capture_output: true, &block)
59
+ end
60
+
46
61
  def snapshot(data)
47
62
  return unless Context.active?
48
63
 
@@ -65,6 +80,52 @@ module Dontbugme
65
80
 
66
81
  private
67
82
 
83
+ def format_output_value(val)
84
+ return nil if val.nil?
85
+
86
+ max = config.max_span_detail_size
87
+ str = if val.is_a?(Array)
88
+ format_array(val)
89
+ elsif val.is_a?(Hash)
90
+ format_hash(val)
91
+ elsif defined?(ActiveRecord::Base) && val.is_a?(ActiveRecord::Base)
92
+ val.inspect
93
+ elsif defined?(ActiveRecord::Relation) && val.is_a?(ActiveRecord::Relation)
94
+ "#{val.to_sql} (relation)"
95
+ else
96
+ val.to_s
97
+ end
98
+ str.bytesize > max ? "#{str.byteslice(0, max)}...[truncated]" : str
99
+ rescue StandardError
100
+ val.class.name
101
+ end
102
+
103
+ def format_array(ary)
104
+ return '[]' if ary.empty?
105
+
106
+ preview = ary.first(5).map { |v| format_single_value(v) }.join(', ')
107
+ ary.size > 5 ? "[#{preview}, ... (#{ary.size} total)]" : "[#{preview}]"
108
+ end
109
+
110
+ def format_hash(hash)
111
+ return '{}' if hash.empty?
112
+
113
+ preview = hash.first(5).map { |k, v| "#{k}: #{format_single_value(v)}" }.join(', ')
114
+ hash.size > 5 ? "{#{preview}, ...}" : "{#{preview}}"
115
+ end
116
+
117
+ def format_single_value(v)
118
+ if defined?(ActiveRecord::Base) && v.is_a?(ActiveRecord::Base)
119
+ v.respond_to?(:id) ? "#<#{v.class.name} id=#{v.id}>" : "#<#{v.class.name}>"
120
+ elsif v.is_a?(Hash)
121
+ '{...}'
122
+ elsif v.is_a?(Array)
123
+ '[...]'
124
+ else
125
+ v.to_s
126
+ end
127
+ end
128
+
68
129
  def build_store
69
130
  store = case config.store
70
131
  when :sqlite
@@ -85,6 +146,7 @@ end
85
146
  require 'dontbugme/version'
86
147
  require 'dontbugme/configuration'
87
148
  require 'dontbugme/span'
149
+ require 'dontbugme/variable_tracker'
88
150
  require 'dontbugme/span_collection'
89
151
  require 'dontbugme/trace'
90
152
  require 'dontbugme/context'
@@ -8,6 +8,9 @@ Dontbugme.configure do |config|
8
8
  # config.enable_web_ui = true
9
9
  # config.web_ui_mount_path = "/inspector"
10
10
 
11
+ # Automatic variable tracking (dev only): captures input/output for local var changes
12
+ # config.capture_variable_changes = true
13
+
11
14
  # Production: use PostgreSQL, async writes, selective recording
12
15
  # config.store = :postgresql
13
16
  # config.async_store = true
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dontbugme
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Inspector Contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-02-28 00:00:00.000000000 Z
11
+ date: 2026-03-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -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
@@ -159,6 +160,7 @@ files:
159
160
  - lib/dontbugme/subscribers/net_http.rb
160
161
  - lib/dontbugme/subscribers/redis.rb
161
162
  - lib/dontbugme/trace.rb
163
+ - lib/dontbugme/variable_tracker.rb
162
164
  - lib/dontbugme/version.rb
163
165
  - lib/generators/dontbugme/install/install_generator.rb
164
166
  - lib/generators/dontbugme/install/templates/dontbugme.rb
File without changes