dontbugme 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/LICENSE +21 -0
- data/README.md +174 -0
- data/app/controllers/dontbugme/traces_controller.rb +45 -0
- data/app/views/dontbugme/traces/diff.html.erb +18 -0
- data/app/views/dontbugme/traces/index.html.erb +30 -0
- data/app/views/dontbugme/traces/show.html.erb +15 -0
- data/app/views/layouts/dontbugme/application.html.erb +56 -0
- data/bin/dontbugme +5 -0
- data/lib/dontbugme/cleanup_job.rb +17 -0
- data/lib/dontbugme/cli.rb +171 -0
- data/lib/dontbugme/config/routes.rb +7 -0
- data/lib/dontbugme/configuration.rb +147 -0
- data/lib/dontbugme/context.rb +25 -0
- data/lib/dontbugme/correlation.rb +25 -0
- data/lib/dontbugme/engine.rb +11 -0
- data/lib/dontbugme/formatters/diff.rb +187 -0
- data/lib/dontbugme/formatters/json.rb +11 -0
- data/lib/dontbugme/formatters/timeline.rb +119 -0
- data/lib/dontbugme/middleware/rack.rb +37 -0
- data/lib/dontbugme/middleware/sidekiq.rb +31 -0
- data/lib/dontbugme/middleware/sidekiq_client.rb +14 -0
- data/lib/dontbugme/railtie.rb +47 -0
- data/lib/dontbugme/recorder.rb +70 -0
- data/lib/dontbugme/source_location.rb +44 -0
- data/lib/dontbugme/span.rb +70 -0
- data/lib/dontbugme/span_collection.rb +40 -0
- data/lib/dontbugme/store/async.rb +45 -0
- data/lib/dontbugme/store/base.rb +23 -0
- data/lib/dontbugme/store/memory.rb +61 -0
- data/lib/dontbugme/store/postgresql.rb +186 -0
- data/lib/dontbugme/store/sqlite.rb +148 -0
- data/lib/dontbugme/subscribers/action_mailer.rb +53 -0
- data/lib/dontbugme/subscribers/active_job.rb +44 -0
- data/lib/dontbugme/subscribers/active_record.rb +81 -0
- data/lib/dontbugme/subscribers/base.rb +19 -0
- data/lib/dontbugme/subscribers/cache.rb +54 -0
- data/lib/dontbugme/subscribers/net_http.rb +87 -0
- data/lib/dontbugme/subscribers/redis.rb +63 -0
- data/lib/dontbugme/trace.rb +142 -0
- data/lib/dontbugme/version.rb +5 -0
- data/lib/dontbugme.rb +118 -0
- data/lib/generators/dontbugme/install/install_generator.rb +17 -0
- data/lib/generators/dontbugme/install/templates/dontbugme.rb +17 -0
- metadata +164 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: fb0afce7d9aa5e4a8f372a7b7b3b2e24ecfd3e5c7cb7e9c05afbb3dee2deb635
|
|
4
|
+
data.tar.gz: 462510e9da9e18d236a65a46e9c25a0b4c8ada876d059e5209b05c63d041bddb
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: '09be1265d9bd33cf45291cb52c7085e2c3f00f95de5f6a7e54f5dcd22522ecf9264413824aaa7d9c4b4eceef8a5edce099f294dfee0d94bc27661bb025e6c070'
|
|
7
|
+
data.tar.gz: e8690b9f9d2937f18751ba50903130e143dc6e4148808ca2f10dba299cf72ad28bbf021e4e9c0d1d247d51894a9c7692067da9165c69c14fea660f9e511aeac7
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Dontbugme
|
|
2
|
+
|
|
3
|
+
A flight recorder for Rails applications. Reconstruct the full execution story of Sidekiq jobs and HTTP requests — see exactly what database queries ran, what HTTP services were called, what exceptions were raised, with source locations pointing to your code.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem 'dontbugme'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then run:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bundle install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
### Automatic Recording
|
|
22
|
+
|
|
23
|
+
Dontbugme automatically records:
|
|
24
|
+
|
|
25
|
+
- **Sidekiq jobs** — via Sidekiq server middleware
|
|
26
|
+
- **HTTP requests** — via Rack middleware
|
|
27
|
+
- **SQL queries** — via ActiveSupport::Notifications
|
|
28
|
+
- **HTTP calls** — Net::HTTP (including Faraday, which uses it)
|
|
29
|
+
- **Redis** — Redis gem operations
|
|
30
|
+
- **Cache** — Rails cache read/write/delete
|
|
31
|
+
- **Mailer** — ActionMailer deliveries
|
|
32
|
+
- **Job enqueue** — Active Job enqueues
|
|
33
|
+
|
|
34
|
+
In development, recording is on by default. Run your app normally, then inspect:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# List recent traces
|
|
38
|
+
bundle exec dontbugme list
|
|
39
|
+
|
|
40
|
+
# Show a specific trace
|
|
41
|
+
bundle exec dontbugme show tr_abc123
|
|
42
|
+
|
|
43
|
+
# Filter spans
|
|
44
|
+
bundle exec dontbugme show tr_abc123 --only=sql
|
|
45
|
+
bundle exec dontbugme show tr_abc123 --slow=10
|
|
46
|
+
|
|
47
|
+
# JSON output
|
|
48
|
+
bundle exec dontbugme show tr_abc123 --json
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Manual Tracing
|
|
52
|
+
|
|
53
|
+
Wrap any block to capture a trace:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
trace = Dontbugme.trace("my debug session") do
|
|
57
|
+
User.find(42)
|
|
58
|
+
Order.where(user_id: 42).count
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
puts trace.to_timeline
|
|
62
|
+
# Or: trace.spans, trace.status, trace.duration_ms
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Manual Spans and Snapshots
|
|
66
|
+
|
|
67
|
+
Add custom spans and snapshots within a trace:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
trace = Dontbugme.trace("checkout flow") do
|
|
71
|
+
Dontbugme.span("Calculate tax") do
|
|
72
|
+
tax = order.calculate_tax
|
|
73
|
+
end
|
|
74
|
+
Dontbugme.snapshot(user: user.attributes.slice("id", "email"), total: order.total)
|
|
75
|
+
Dontbugme.tag(customer_tier: "enterprise")
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Span Categories
|
|
80
|
+
|
|
81
|
+
Access spans by category for assertions or analysis:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
trace.spans.sql # SQL queries
|
|
85
|
+
trace.spans.http # HTTP calls
|
|
86
|
+
trace.spans.redis # Redis operations
|
|
87
|
+
trace.spans.category(:mailer) # Any category
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Search
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
bundle exec dontbugme search --status=error --class=SendInvoiceJob --limit=10
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Trace Diff
|
|
97
|
+
|
|
98
|
+
Compare two executions to see what changed:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
bundle exec dontbugme diff tr_success tr_failed
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Shows IDENTICAL, CHANGED, MISSING, and NEW spans between the two traces.
|
|
105
|
+
|
|
106
|
+
### Correlation Chain
|
|
107
|
+
|
|
108
|
+
When a request enqueues jobs, they share a `correlation_id`. Follow the full chain:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
bundle exec dontbugme trace tr_request_id --follow
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Shows all traces (request + enqueued jobs) with the same correlation ID. Correlation IDs are automatically propagated from HTTP requests to Sidekiq jobs (and from job to child job) when using the Rails integration.
|
|
115
|
+
|
|
116
|
+
### Web UI (optional)
|
|
117
|
+
|
|
118
|
+
A lightweight web interface to browse traces. **Disabled by default in production.** Enable in development:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
# config/initializers/dontbugme.rb
|
|
122
|
+
Dontbugme.configure do |config|
|
|
123
|
+
config.enable_web_ui = true # default: true in dev, false in prod
|
|
124
|
+
config.web_ui_mount_path = '/inspector'
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Add to `config/routes.rb`:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
mount Dontbugme::Engine, at: '/inspector' if Dontbugme.config.enable_web_ui
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Then visit `/inspector` to browse traces, search, and compare.
|
|
135
|
+
|
|
136
|
+
### Configuration
|
|
137
|
+
|
|
138
|
+
Create `config/initializers/dontbugme.rb`:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
Dontbugme.configure do |config|
|
|
142
|
+
config.store = :sqlite
|
|
143
|
+
config.sqlite_path = "tmp/inspector/inspector.db"
|
|
144
|
+
config.recording_mode = :always
|
|
145
|
+
config.capture_sql_binds = true
|
|
146
|
+
config.source_mode = :full
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Storage
|
|
151
|
+
|
|
152
|
+
- **SQLite** (default): Zero config. Data at `tmp/inspector/inspector.db`
|
|
153
|
+
- **PostgreSQL**: Uses your Rails DB. Set `config.store = :postgresql`
|
|
154
|
+
- **Memory**: For tests. Traces lost on process exit.
|
|
155
|
+
|
|
156
|
+
## Cleanup
|
|
157
|
+
|
|
158
|
+
Traces are ephemeral. Run cleanup to enforce retention:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
Dontbugme::CleanupJob.perform
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Schedule via cron or Sidekiq to run periodically.
|
|
165
|
+
|
|
166
|
+
## Requirements
|
|
167
|
+
|
|
168
|
+
- Ruby >= 3.0
|
|
169
|
+
- Rails >= 7.0 (for full integration)
|
|
170
|
+
- Sidekiq >= 7.0 (optional, for job tracing)
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dontbugme
|
|
4
|
+
class TracesController < ActionController::Base
|
|
5
|
+
layout 'dontbugme/application'
|
|
6
|
+
|
|
7
|
+
before_action :ensure_enabled
|
|
8
|
+
|
|
9
|
+
def index
|
|
10
|
+
filters = { limit: 50 }
|
|
11
|
+
filters[:status] = params[:status] if params[:status].present?
|
|
12
|
+
filters[:kind] = params[:kind] if params[:kind].present?
|
|
13
|
+
filters[:identifier] = params[:q] if params[:q].present?
|
|
14
|
+
filters[:correlation_id] = params[:correlation_id] if params[:correlation_id].present?
|
|
15
|
+
@traces = store.search(filters)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def show
|
|
19
|
+
@trace = store.find_trace(params[:id])
|
|
20
|
+
return render plain: 'Trace not found', status: :not_found unless @trace
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def diff
|
|
24
|
+
@trace_a = params[:a].present? ? store.find_trace(params[:a]) : nil
|
|
25
|
+
@trace_b = params[:b].present? ? store.find_trace(params[:b]) : nil
|
|
26
|
+
@diff_output = if @trace_a && @trace_b
|
|
27
|
+
Formatters::Diff.format(@trace_a, @trace_b)
|
|
28
|
+
else
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def store
|
|
36
|
+
Dontbugme.store
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def ensure_enabled
|
|
40
|
+
return if Dontbugme.config.enable_web_ui
|
|
41
|
+
|
|
42
|
+
render plain: 'Dontbugme Web UI is disabled. Set config.enable_web_ui = true to enable.', status: :forbidden
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<%= form_with url: diff_path, method: :get, local: true, class: 'diff-form' do %>
|
|
2
|
+
<input type="text" name="a" value="<%= params[:a] %>" placeholder="Trace A ID (e.g. tr_abc123)">
|
|
3
|
+
<span>vs</span>
|
|
4
|
+
<input type="text" name="b" value="<%= params[:b] %>" placeholder="Trace B ID (e.g. tr_def456)">
|
|
5
|
+
<button type="submit">Compare</button>
|
|
6
|
+
<% end %>
|
|
7
|
+
|
|
8
|
+
<% if @diff_output %>
|
|
9
|
+
<div class="card">
|
|
10
|
+
<div class="diff-output"><%= @diff_output %></div>
|
|
11
|
+
</div>
|
|
12
|
+
<% elsif params[:a].present? || params[:b].present? %>
|
|
13
|
+
<p class="empty">Enter two trace IDs to compare. Use the trace list to find IDs.</p>
|
|
14
|
+
<% else %>
|
|
15
|
+
<p class="empty">Enter two trace IDs above to see what changed between two executions.</p>
|
|
16
|
+
<% end %>
|
|
17
|
+
|
|
18
|
+
<p style="margin-top: 16px;"><%= link_to '← Back to traces', root_path %></p>
|
|
@@ -0,0 +1,30 @@
|
|
|
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;">
|
|
3
|
+
<select name="status">
|
|
4
|
+
<option value="">All statuses</option>
|
|
5
|
+
<option value="success" <%= params[:status] == 'success' ? 'selected' : '' %>>Success</option>
|
|
6
|
+
<option value="error" <%= params[:status] == 'error' ? 'selected' : '' %>>Error</option>
|
|
7
|
+
</select>
|
|
8
|
+
<select name="kind">
|
|
9
|
+
<option value="">All kinds</option>
|
|
10
|
+
<option value="sidekiq" <%= params[:kind] == 'sidekiq' ? 'selected' : '' %>>Sidekiq</option>
|
|
11
|
+
<option value="request" <%= params[:kind] == 'request' ? 'selected' : '' %>>Request</option>
|
|
12
|
+
<option value="custom" <%= params[:kind] == 'custom' ? 'selected' : '' %>>Custom</option>
|
|
13
|
+
</select>
|
|
14
|
+
<button type="submit">Filter</button>
|
|
15
|
+
<% end %>
|
|
16
|
+
|
|
17
|
+
<div class="card">
|
|
18
|
+
<% if @traces.any? %>
|
|
19
|
+
<% @traces.each do |trace| %>
|
|
20
|
+
<div class="trace-row">
|
|
21
|
+
<span class="trace-id"><%= link_to trace.id, trace_path(trace.id) %></span>
|
|
22
|
+
<span class="trace-identifier"><%= trace.identifier %></span>
|
|
23
|
+
<span class="badge <%= trace.status == :success ? 'badge-success' : 'badge-error' %>"><%= trace.status %></span>
|
|
24
|
+
<span class="trace-duration"><%= trace.duration_ms ? "#{trace.duration_ms.round}ms" : '-' %></span>
|
|
25
|
+
</div>
|
|
26
|
+
<% end %>
|
|
27
|
+
<% else %>
|
|
28
|
+
<p class="empty">No traces found. Run your app and execute some jobs or requests.</p>
|
|
29
|
+
<% end %>
|
|
30
|
+
</div>
|
|
@@ -0,0 +1,15 @@
|
|
|
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) %>
|
|
7
|
+
<% end %>
|
|
8
|
+
</p>
|
|
9
|
+
<div class="timeline"><%= Formatters::Timeline.format(@trace) %></div>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
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>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Dontbugme</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { 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; }
|
|
13
|
+
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; }
|
|
23
|
+
.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; }
|
|
29
|
+
.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; }
|
|
36
|
+
.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; }
|
|
40
|
+
</style>
|
|
41
|
+
</head>
|
|
42
|
+
<body>
|
|
43
|
+
<header>
|
|
44
|
+
<div class="container">
|
|
45
|
+
<h1>Dontbugme</h1>
|
|
46
|
+
<nav>
|
|
47
|
+
<%= link_to 'Traces', root_path, class: (controller_name == 'traces' && action_name == 'index' ? 'active' : '') %>
|
|
48
|
+
<%= link_to 'Diff', diff_path, class: (action_name == 'diff' ? 'active' : '') %>
|
|
49
|
+
</nav>
|
|
50
|
+
</div>
|
|
51
|
+
</header>
|
|
52
|
+
<main class="container">
|
|
53
|
+
<%= yield %>
|
|
54
|
+
</main>
|
|
55
|
+
</body>
|
|
56
|
+
</html>
|
data/bin/dontbugme
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dontbugme
|
|
4
|
+
class CleanupJob
|
|
5
|
+
def self.perform
|
|
6
|
+
store = Dontbugme.store
|
|
7
|
+
return unless store
|
|
8
|
+
|
|
9
|
+
config = Dontbugme.config
|
|
10
|
+
retention = config.retention
|
|
11
|
+
return unless retention
|
|
12
|
+
|
|
13
|
+
cutoff = Time.now - retention
|
|
14
|
+
store.cleanup(before: cutoff)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
|
|
5
|
+
module Dontbugme
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
desc 'list', 'List recent traces'
|
|
8
|
+
option :limit, type: :numeric, default: 20, aliases: '-n'
|
|
9
|
+
def list
|
|
10
|
+
ensure_loaded
|
|
11
|
+
traces = store.search(limit: options[:limit])
|
|
12
|
+
display_list(traces)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
desc 'show TRACE_ID', 'Show a single trace'
|
|
16
|
+
option :only, type: :string, desc: 'Filter spans by category (sql, http, etc.)'
|
|
17
|
+
option :slow, type: :numeric, desc: 'Only show spans slower than N ms'
|
|
18
|
+
option :json, type: :boolean, default: false
|
|
19
|
+
def show(trace_id)
|
|
20
|
+
ensure_loaded
|
|
21
|
+
trace = store.find_trace(trace_id)
|
|
22
|
+
if trace.nil?
|
|
23
|
+
puts "Trace not found: #{trace_id}"
|
|
24
|
+
exit 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
if options[:json]
|
|
28
|
+
puts Formatters::Json.format(trace)
|
|
29
|
+
else
|
|
30
|
+
puts Formatters::Timeline.format(trace, only: options[:only], slow: options[:slow])
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
desc 'diff TRACE_A TRACE_B', 'Compare two traces'
|
|
35
|
+
def diff(trace_id_a, trace_id_b)
|
|
36
|
+
ensure_loaded
|
|
37
|
+
trace_a = store.find_trace(trace_id_a)
|
|
38
|
+
trace_b = store.find_trace(trace_id_b)
|
|
39
|
+
if trace_a.nil?
|
|
40
|
+
puts "Trace not found: #{trace_id_a}"
|
|
41
|
+
exit 1
|
|
42
|
+
end
|
|
43
|
+
if trace_b.nil?
|
|
44
|
+
puts "Trace not found: #{trace_id_b}"
|
|
45
|
+
exit 1
|
|
46
|
+
end
|
|
47
|
+
puts Formatters::Diff.format(trace_a, trace_b)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
desc 'trace TRACE_ID', 'Show trace and follow correlation chain'
|
|
51
|
+
option :follow, type: :boolean, default: false, aliases: '-f'
|
|
52
|
+
def trace(trace_id)
|
|
53
|
+
ensure_loaded
|
|
54
|
+
root = store.find_trace(trace_id)
|
|
55
|
+
if root.nil?
|
|
56
|
+
puts "Trace not found: #{trace_id}"
|
|
57
|
+
exit 1
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if options[:follow]
|
|
61
|
+
cid = root.correlation_id || root.metadata[:correlation_id]
|
|
62
|
+
if cid.nil? || cid.to_s.empty?
|
|
63
|
+
puts "No correlation ID for trace #{trace_id}. Showing single trace."
|
|
64
|
+
puts Formatters::Timeline.format(root)
|
|
65
|
+
return
|
|
66
|
+
end
|
|
67
|
+
traces = store.search(correlation_id: cid, limit: 100)
|
|
68
|
+
puts format_correlation_tree(traces, root)
|
|
69
|
+
else
|
|
70
|
+
puts Formatters::Timeline.format(root)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
desc 'search', 'Search traces'
|
|
75
|
+
option :status, type: :string, desc: 'Filter by status (success, error)'
|
|
76
|
+
option :kind, type: :string, desc: 'Filter by kind (sidekiq, request, custom)'
|
|
77
|
+
option :identifier, type: :string, desc: 'Filter by identifier (partial match)'
|
|
78
|
+
option :class, type: :string, desc: 'Filter by job/request class (e.g. SendInvoiceJob)'
|
|
79
|
+
option :limit, type: :numeric, default: 20
|
|
80
|
+
def search
|
|
81
|
+
ensure_loaded
|
|
82
|
+
filters = { limit: options[:limit] }
|
|
83
|
+
filters[:status] = options[:status] if options[:status]
|
|
84
|
+
filters[:kind] = options[:kind] if options[:kind]
|
|
85
|
+
filters[:identifier] = options[:identifier] || options[:class]
|
|
86
|
+
traces = store.search(filters)
|
|
87
|
+
display_list(traces)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
default_task :list
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def ensure_loaded
|
|
95
|
+
# Gem is loaded by bin/dontbugme; this is a no-op when used as a gem
|
|
96
|
+
require 'dontbugme' unless defined?(Dontbugme)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def store
|
|
100
|
+
Dontbugme.store
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def display_list(traces)
|
|
104
|
+
return puts('No traces found.') if traces.empty?
|
|
105
|
+
|
|
106
|
+
rows = traces.map do |trace|
|
|
107
|
+
[
|
|
108
|
+
trace.id,
|
|
109
|
+
truncate(trace.identifier, 24),
|
|
110
|
+
status_icon(trace.status),
|
|
111
|
+
duration_str(trace)
|
|
112
|
+
]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Simple table
|
|
116
|
+
col_widths = [12, 26, 6, 10]
|
|
117
|
+
puts ''
|
|
118
|
+
puts row_str(['Trace ID', 'Identifier', 'Status', 'Duration'], col_widths)
|
|
119
|
+
puts row_str(['-' * 12, '-' * 24, '-' * 4, '-' * 8], col_widths)
|
|
120
|
+
rows.each { |r| puts row_str(r, col_widths) }
|
|
121
|
+
puts ''
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def row_str(cols, widths)
|
|
125
|
+
cols.each_with_index.map { |c, i| c.to_s.ljust(widths[i]) }.join(' ')
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def truncate(str, max)
|
|
129
|
+
return str if str.length <= max
|
|
130
|
+
|
|
131
|
+
"#{str[0, max - 3]}..."
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def status_icon(status)
|
|
135
|
+
status.to_s == 'success' ? '✓' : '✗'
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def duration_str(trace)
|
|
139
|
+
ms = trace.duration_ms
|
|
140
|
+
ms ? "#{ms.round}ms" : '-'
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def format_correlation_tree(traces, root)
|
|
144
|
+
lines = []
|
|
145
|
+
lines << ''
|
|
146
|
+
lines << " Correlation: #{root.correlation_id || root.metadata[:correlation_id]}"
|
|
147
|
+
lines << ' ' + ('─' * 60)
|
|
148
|
+
lines << ''
|
|
149
|
+
|
|
150
|
+
# Order: request first, then jobs by started_at
|
|
151
|
+
sorted = traces.sort_by do |t|
|
|
152
|
+
kind_order = { request: 0, sidekiq: 1, custom: 2 }
|
|
153
|
+
[kind_order[t.kind] || 3, t.started_at_utc.to_s]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
display_trace = lambda do |t|
|
|
157
|
+
icon = t.status == :success ? '✓' : '✗'
|
|
158
|
+
duration = t.duration_ms ? "#{t.duration_ms.round}ms" : '-'
|
|
159
|
+
"#{t.identifier} (#{t.id}, #{duration}, #{t.status})"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
sorted.each_with_index do |t, i|
|
|
163
|
+
prefix = i.zero? ? '' : (i == sorted.size - 1 ? '└── ' : '├── ')
|
|
164
|
+
lines << " #{prefix}#{display_trace.call(t)}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
lines << ''
|
|
168
|
+
lines.join("\n")
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|