dispatch-rails 0.7.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/README.md +227 -0
- data/app/assets/javascripts/dispatch/error_tracker.js +152 -0
- data/app/assets/javascripts/dispatch/widget.js +293 -0
- data/app/helpers/dispatch/widget_helper.rb +65 -0
- data/app/views/dispatch/_error_tracker.html.erb +9 -0
- data/app/views/dispatch/_widget.html.erb +71 -0
- data/lib/dispatch/rails/configuration.rb +135 -0
- data/lib/dispatch/rails/engine.rb +61 -0
- data/lib/dispatch/rails/error_subscriber.rb +32 -0
- data/lib/dispatch/rails/event_builder.rb +257 -0
- data/lib/dispatch/rails/heartbeat_aggregator.rb +110 -0
- data/lib/dispatch/rails/heartbeat_middleware.rb +60 -0
- data/lib/dispatch/rails/middleware.rb +20 -0
- data/lib/dispatch/rails/rake_handler.rb +19 -0
- data/lib/dispatch/rails/reporter.rb +93 -0
- data/lib/dispatch/rails/response_annotator.rb +107 -0
- data/lib/dispatch/rails/transport.rb +134 -0
- data/lib/dispatch/rails/version.rb +5 -0
- data/lib/dispatch-rails.rb +108 -0
- metadata +83 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 300eb860cc2bb5f7dff2322818cc6bd552507695c71c84c9be81ccbd6e34a3fd
|
|
4
|
+
data.tar.gz: 4070748d3d36256a456e9eb960d36c47418ec55f699e0984a9fd49c4d67b501f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1dcbd3e9a81c078086e62e5e50bca81679832a2dff5e5fd1fe5b4a82bc052e445adb83dbc8e1f7c22b8699e94d6a7d5a6d843d2ae7f974a97cabf682289d767d
|
|
7
|
+
data.tar.gz: 21765b8506a454e84d225b8d932ce6a15dc2cddd948d8247cddae59908cfe1499139f607a90892e1dffa247a299f156b3c09e1100a73efeb2ae8243af9a7a133
|
data/README.md
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# dispatch-rails
|
|
2
|
+
|
|
3
|
+
Feed [Dispatch](https://dispatchit.app), the AI-native ticketing system, from any
|
|
4
|
+
Rails app. Two ways in:
|
|
5
|
+
|
|
6
|
+
- **API-only / Errors** β headless server-side exception tracking for apps with no
|
|
7
|
+
UI (API services, background workers). No widget; just automatic capture,
|
|
8
|
+
structured error responses, and a programmatic report helper.
|
|
9
|
+
- **Widget** β a floating π¬ feedback button for apps that have a UI, where a
|
|
10
|
+
human reports a bug, requests a feature, or suggests a change, and the
|
|
11
|
+
report ships with browser context.
|
|
12
|
+
|
|
13
|
+
Both modes authenticate with the same project API token and share the same error
|
|
14
|
+
pipeline.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
# Gemfile
|
|
20
|
+
gem "dispatch-rails", "~> 0.6"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## API-only / Errors mode
|
|
26
|
+
|
|
27
|
+
For an app with no UI, set `mode: :errors_only`. Server-side capture is on by
|
|
28
|
+
default once configured β a Rack middleware catches unhandled request exceptions
|
|
29
|
+
(with full request + user context) and a `Rails.error` subscriber catches
|
|
30
|
+
background/job errors. The widget and browser-error tags become no-ops.
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# config/initializers/dispatch.rb
|
|
34
|
+
Dispatch::Rails.configure do |c|
|
|
35
|
+
c.mode = :errors_only
|
|
36
|
+
c.api_key = Rails.application.credentials.dig(:dispatch, :api_key)
|
|
37
|
+
c.endpoint = "https://dispatchit.app/api/v1/tickets" # host is also used to derive the error + report URLs
|
|
38
|
+
c.release = ENV["GIT_SHA"]
|
|
39
|
+
c.enabled_environments = %w[production staging]
|
|
40
|
+
|
|
41
|
+
# Resolve the affected user from your API auth (see "Identity" below).
|
|
42
|
+
c.user = ->(ctx) { ctx.request.env["warden"]&.user&.then { { email: _1.email, external_id: _1.id } } }
|
|
43
|
+
c.context = ->(ctx) { { request_id: ctx.request.request_id }.compact }
|
|
44
|
+
|
|
45
|
+
# Structured error responses (opt-in).
|
|
46
|
+
c.structured_error_responses = true
|
|
47
|
+
c.annotate_error_body = false # headers only by default; true also merges into JSON error bodies
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Identity (the contract that matters for API apps)
|
|
52
|
+
|
|
53
|
+
The `user` and `context` lambdas receive the **controller instance** (not a
|
|
54
|
+
session). That's the seam for API auth β read whatever identifies the caller:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
c.user = ->(ctx) {
|
|
58
|
+
u = ctx.try(:current_user) ||
|
|
59
|
+
ctx.request.env["warden"]&.user ||
|
|
60
|
+
((k = ctx.request.headers["X-API-Key"]) && User.find_by(api_key: k))
|
|
61
|
+
u && { email: u.email, external_id: u.id }
|
|
62
|
+
}
|
|
63
|
+
c.context = ->(ctx) { { player_id: ctx.request.headers["X-Player-ID"] }.compact }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`context` returns extra tags merged into every event (your explicit `context[:tags]`
|
|
67
|
+
on a manual capture still win). For background-job errors there is no controller,
|
|
68
|
+
so both lambdas receive `nil` β guard accordingly.
|
|
69
|
+
|
|
70
|
+
### What's captured
|
|
71
|
+
|
|
72
|
+
Each event is a Sentry-shaped payload: the exception chain with source context for
|
|
73
|
+
in-app frames, `transaction` (`controller#action`), `tags.request_id` (the Rails
|
|
74
|
+
request id), the request URL/method/headers, the resolved user, and your `context`
|
|
75
|
+
tags. Request params are **off by default**; opt in with `c.send_default_params = true`
|
|
76
|
+
(uses Rails' `filtered_parameters`, so your `config.filter_parameters` redactions
|
|
77
|
+
apply β add a `before_send` for stricter scrubbing).
|
|
78
|
+
|
|
79
|
+
### Process lifecycle (boot crashes, rake failures, shutdown flush)
|
|
80
|
+
|
|
81
|
+
Errors that never reach the Rack stack are still captured:
|
|
82
|
+
|
|
83
|
+
- **Crash at exit** β an unhandled exception that kills the process (a boot
|
|
84
|
+
crash in an initializer, a dying `rails runner` script) is reported from an
|
|
85
|
+
`at_exit` hook, tagged `source: at_exit`. Normal exits and SIGTERM-driven
|
|
86
|
+
graceful shutdowns are ignored.
|
|
87
|
+
- **Rake task failures** β rake rescues the real exception itself, so the gem
|
|
88
|
+
patches `Rake::Application#display_error_message` and reports the failure
|
|
89
|
+
tagged `source: rake` with the failing command.
|
|
90
|
+
- **Shutdown flush** β events are sent from a background queue; on process
|
|
91
|
+
exit the in-progress traffic-heartbeat window is shipped and the queue is
|
|
92
|
+
drained (up to `shutdown_timeout` seconds) so restarts and deploys don't
|
|
93
|
+
drop reports β or the final window of confound-guard counts β captured
|
|
94
|
+
moments earlier.
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
c.capture_at_exit = true # default; set false to skip the crash-at-exit report
|
|
98
|
+
c.shutdown_timeout = 3 # seconds to wait for the send queue to drain at exit; 0 skips
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Traffic heartbeats (confound signal)
|
|
102
|
+
|
|
103
|
+
On by default in enabled environments, the SDK ships lightweight per-`transaction`
|
|
104
|
+
request/error counts β one small aggregate POST per minute, regardless of request
|
|
105
|
+
volume. Dispatch uses these so fix verification can tell *"the error stopped because
|
|
106
|
+
we fixed it"* from *"β¦because nobody hits that path anymore"*: a fix is only marked
|
|
107
|
+
verified if the affected `controller#action` is still serving successful traffic.
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
c.capture_traffic = true # default; set false to disable
|
|
111
|
+
c.traffic_sample_rate = 1.0 # independent of error_sample_rate
|
|
112
|
+
c.heartbeat_flush_seconds = 60 # aggregation window
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
No request bodies, params, or user data are sent β only counts keyed by
|
|
116
|
+
`controller#action`.
|
|
117
|
+
|
|
118
|
+
### Structured error responses
|
|
119
|
+
|
|
120
|
+
With `c.structured_error_responses = true`, any 4xx/5xx response carries:
|
|
121
|
+
|
|
122
|
+
- `X-Dispatch-Request-Id: <request id>` β the correlation key
|
|
123
|
+
- `X-Dispatch-Report-Url: <link>` β informational
|
|
124
|
+
|
|
125
|
+
This is the API-only analogue of the widget: it hands the caller an id they can
|
|
126
|
+
quote. With `c.annotate_error_body = true`, the same fields (`dispatch_request_id`,
|
|
127
|
+
`dispatch_report_url`) are merged into JSON error bodies too (RFC 7807-friendly β
|
|
128
|
+
they sit beside `type`/`title`/`detail`). The middleware never changes status codes
|
|
129
|
+
and passes through anything it can't safely parse.
|
|
130
|
+
|
|
131
|
+
### Curated reports (programmatic / agent)
|
|
132
|
+
|
|
133
|
+
A consumer (or an AI agent inside your app) turns a failure into a tracked report:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
Dispatch::Rails.report(
|
|
137
|
+
description: "Nightly import aborted: upstream returned 502",
|
|
138
|
+
severity: "high",
|
|
139
|
+
correlation_id: request.request_id, # links to the captured error
|
|
140
|
+
metadata: { job: "ImportJob" }
|
|
141
|
+
)
|
|
142
|
+
# => { "id" => 123, "status" => "inbox", "url" => "https://acme.dispatchit.app/tickets/123" }
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Or over HTTP, quoting the id from `X-Dispatch-Request-Id`:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
curl -X POST https://dispatchit.app/api/v1/tickets \
|
|
149
|
+
-H "Authorization: Bearer dsp_live_β¦" -H "Content-Type: application/json" \
|
|
150
|
+
-d '{"ticket":{"description":"Checkout 500s on empty cart","severity":"high",
|
|
151
|
+
"source":"api","metadata":{"correlation_id":"abc123"}}}'
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Dispatch links the resulting ticket to the captured error's group. (An MCP server /
|
|
155
|
+
CLI wrapping this is a planned agent-workflow extension.)
|
|
156
|
+
|
|
157
|
+
### Manual capture
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
rescue => e
|
|
161
|
+
Dispatch::Rails.capture_exception(e, context: { tags: { area: "import" } })
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Not on Rails?
|
|
166
|
+
|
|
167
|
+
Any language with a Sentry SDK can point at Dispatch via a DSN β see
|
|
168
|
+
[docs/sentry-dsn.md](docs/sentry-dsn.md).
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Widget mode
|
|
173
|
+
|
|
174
|
+
For apps with a UI. Configure, then render the widget in your layout:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
# config/initializers/dispatch.rb
|
|
178
|
+
Dispatch::Rails.configure do |c|
|
|
179
|
+
c.api_key = Rails.application.credentials.dig(:dispatch, :api_key)
|
|
180
|
+
c.endpoint = "https://dispatchit.app/api/v1/tickets"
|
|
181
|
+
c.user = ->(ctx) { ctx.current_user&.then { { email: _1.email, external_id: _1.id } } }
|
|
182
|
+
c.metadata = ->(ctx) { { release: ENV["GIT_SHA"], env: Rails.env } }
|
|
183
|
+
end
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
```erb
|
|
187
|
+
<%# In your layout, e.g. on staging %>
|
|
188
|
+
<% if Rails.env.staging? %>
|
|
189
|
+
<%= dispatch_widget_tag %>
|
|
190
|
+
<% end %>
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Per-page severity / labels
|
|
194
|
+
|
|
195
|
+
```erb
|
|
196
|
+
<%= dispatch_widget_tag severity: :critical, labels: %w[checkout payments] %>
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
These attach to every report from that page (`ticket.severity`, `metadata.labels`).
|
|
200
|
+
|
|
201
|
+
### What the widget captures
|
|
202
|
+
|
|
203
|
+
- `location.href`, `navigator.userAgent`, viewport size, `document.referrer`
|
|
204
|
+
- **User path** β the last 5 things the user clicked, as `metadata.user_path`
|
|
205
|
+
(`c.capture_clicks = true`, the default)
|
|
206
|
+
- Optional: last 20 `console.error` entries (`c.capture_console = true`)
|
|
207
|
+
- Whatever your `user` and `metadata` lambdas return, plus per-tag severity/labels
|
|
208
|
+
|
|
209
|
+
It `POST`s to your `endpoint` with `Authorization: Bearer <api_key>`, an
|
|
210
|
+
auto-generated `Idempotency-Key`, and `{ ticket: { description, source: "widget",
|
|
211
|
+
severity, reporter, metadata } }`.
|
|
212
|
+
|
|
213
|
+
### Browser exception tracking
|
|
214
|
+
|
|
215
|
+
Add the tracker to your layout `<head>` to capture uncaught JS errors and
|
|
216
|
+
unhandled promise rejections (same project key, same `user` lambda for the
|
|
217
|
+
affected-user email loop):
|
|
218
|
+
|
|
219
|
+
```erb
|
|
220
|
+
<%= dispatch_error_tracker_tag %>
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Multiple projects per app
|
|
224
|
+
|
|
225
|
+
Each Dispatch project has its own API key. To send reports from different surfaces
|
|
226
|
+
to different projects, create multiple initializers or wrap
|
|
227
|
+
`Dispatch::Rails.configuration.api_key` with a per-request override.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Dispatch browser error tracker. Captures uncaught errors and unhandled promise
|
|
2
|
+
// rejections, builds a Sentry-shaped event, and ships it to the Dispatch ingest
|
|
3
|
+
// endpoint. Plain module (not Stimulus) so it installs its handlers immediately.
|
|
4
|
+
|
|
5
|
+
(function () {
|
|
6
|
+
var el = document.getElementById("dispatch-error-config");
|
|
7
|
+
var cfg = el ? safeParse(el.textContent) : window.__dispatchErrorConfig;
|
|
8
|
+
if (!cfg || !cfg.endpoint || !cfg.apiKey) return;
|
|
9
|
+
if (window.__dispatchErrorTrackerLoaded) return;
|
|
10
|
+
window.__dispatchErrorTrackerLoaded = true;
|
|
11
|
+
|
|
12
|
+
var MAX_CRUMBS = 30;
|
|
13
|
+
var breadcrumbs = [];
|
|
14
|
+
|
|
15
|
+
function safeParse(s) { try { return JSON.parse(s); } catch (e) { return null; } }
|
|
16
|
+
function now() { return Date.now() / 1000; }
|
|
17
|
+
function crumb(c) { breadcrumbs.push(c); if (breadcrumbs.length > MAX_CRUMBS) breadcrumbs.shift(); }
|
|
18
|
+
|
|
19
|
+
function uuid() {
|
|
20
|
+
if (window.crypto && crypto.randomUUID) return crypto.randomUUID().replace(/-/g, "");
|
|
21
|
+
return (Date.now().toString(16) + Math.random().toString(16).slice(2) + "0".repeat(32)).slice(0, 32);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A short, human-readable label for a clicked element (used for breadcrumbs
|
|
25
|
+
// and the "user path" β the last few things the user clicked).
|
|
26
|
+
function describeClickTarget(node) {
|
|
27
|
+
var el = node && node.nodeType === 1 ? node : null;
|
|
28
|
+
if (!el) return null;
|
|
29
|
+
if (el.closest) {
|
|
30
|
+
el = el.closest("button, a, input, select, textarea, label, [role='button'], [role='link'], [data-action]") || el;
|
|
31
|
+
}
|
|
32
|
+
var tag = (el.tagName || "").toLowerCase();
|
|
33
|
+
var text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 40);
|
|
34
|
+
var attr = el.getAttribute &&
|
|
35
|
+
(el.getAttribute("aria-label") || el.getAttribute("title") || el.getAttribute("name") || el.getAttribute("placeholder"));
|
|
36
|
+
var id = el.id ? "#" + el.id : "";
|
|
37
|
+
var label = text || attr;
|
|
38
|
+
if (label) return (tag + id + ' "' + label + '"').slice(0, 80);
|
|
39
|
+
var cls = (typeof el.className === "string" && el.className.trim()) ? "." + el.className.trim().split(/\s+/)[0] : "";
|
|
40
|
+
return (tag + id + cls).slice(0, 80) || tag || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The last few clicks, oldestβnewest β a concise path to the error.
|
|
44
|
+
function userPath() {
|
|
45
|
+
return breadcrumbs
|
|
46
|
+
.filter(function (b) { return b.category === "ui.click"; })
|
|
47
|
+
.slice(-5)
|
|
48
|
+
.map(function (b) { return b.message; });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Breadcrumbs: clicks, navigation, console.error.
|
|
52
|
+
if (cfg.captureClicks !== false) {
|
|
53
|
+
document.addEventListener("click", function (e) {
|
|
54
|
+
var label = describeClickTarget(e.target);
|
|
55
|
+
if (label) crumb({ timestamp: now(), category: "ui.click", level: "info", message: label });
|
|
56
|
+
}, true);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
var origConsoleError = console.error;
|
|
60
|
+
console.error = function () {
|
|
61
|
+
try {
|
|
62
|
+
crumb({ timestamp: now(), category: "console", level: "error",
|
|
63
|
+
message: Array.prototype.map.call(arguments, String).join(" ").slice(0, 500) });
|
|
64
|
+
} finally { origConsoleError.apply(console, arguments); }
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function parseStack(stack) {
|
|
68
|
+
if (!stack) return [];
|
|
69
|
+
var frames = [];
|
|
70
|
+
stack.split("\n").forEach(function (line) {
|
|
71
|
+
var m = line.match(/at\s+(.*?)\s+\((.*?):(\d+):(\d+)\)/) ||
|
|
72
|
+
line.match(/at\s+(.*?):(\d+):(\d+)/) ||
|
|
73
|
+
line.match(/(.*?)@(.*?):(\d+):(\d+)/);
|
|
74
|
+
if (!m) return;
|
|
75
|
+
var fn, url, ln, col;
|
|
76
|
+
if (m.length === 5) { fn = m[1]; url = m[2]; ln = m[3]; col = m[4]; }
|
|
77
|
+
else { fn = "?"; url = m[1]; ln = m[2]; col = m[3]; }
|
|
78
|
+
frames.push({
|
|
79
|
+
function: fn, filename: url, abs_path: url,
|
|
80
|
+
lineno: parseInt(ln, 10), colno: parseInt(col, 10),
|
|
81
|
+
in_app: url.indexOf("node_modules") === -1 && url.indexOf("http") === 0
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
return frames.reverse(); // oldest first, Sentry-style
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildEvent(type, value, stack) {
|
|
88
|
+
return {
|
|
89
|
+
event_id: uuid(),
|
|
90
|
+
timestamp: now(),
|
|
91
|
+
platform: "javascript",
|
|
92
|
+
level: "error",
|
|
93
|
+
environment: cfg.environment,
|
|
94
|
+
release: cfg.release,
|
|
95
|
+
exception: { values: [{
|
|
96
|
+
type: type || "Error",
|
|
97
|
+
value: String(value == null ? "" : value).slice(0, 2000),
|
|
98
|
+
mechanism: { type: "onerror", handled: false },
|
|
99
|
+
stacktrace: { frames: parseStack(stack) }
|
|
100
|
+
}] },
|
|
101
|
+
breadcrumbs: { values: breadcrumbs.slice() },
|
|
102
|
+
user_path: userPath(),
|
|
103
|
+
request: { url: location.href, headers: { "User-Agent": navigator.userAgent } },
|
|
104
|
+
user: cfg.user || null,
|
|
105
|
+
tags: cfg.tags || {}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sampledOut() {
|
|
110
|
+
var rate = cfg.sampleRate == null ? 1 : cfg.sampleRate;
|
|
111
|
+
return Math.random() > rate;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function send(event) {
|
|
115
|
+
if (sampledOut()) return;
|
|
116
|
+
var body = JSON.stringify(event);
|
|
117
|
+
// Preferred: fetch keepalive (proper CORS + Authorization header).
|
|
118
|
+
if (window.fetch) {
|
|
119
|
+
try {
|
|
120
|
+
fetch(cfg.endpoint, {
|
|
121
|
+
method: "POST", keepalive: true, mode: "cors",
|
|
122
|
+
headers: { "Content-Type": "application/json", "Authorization": "Bearer " + cfg.apiKey },
|
|
123
|
+
body: body
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
} catch (e) { /* fall through */ }
|
|
127
|
+
}
|
|
128
|
+
// Fallback for unload: sendBeacon can't set headers, so use ?sentry_key=.
|
|
129
|
+
if (navigator.sendBeacon) {
|
|
130
|
+
var url = cfg.endpoint + (cfg.endpoint.indexOf("?") >= 0 ? "&" : "?") + "sentry_key=" + encodeURIComponent(cfg.apiKey);
|
|
131
|
+
try { navigator.sendBeacon(url, new Blob([body], { type: "application/json" })); } catch (e) { /* noop */ }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
window.addEventListener("error", function (e) {
|
|
136
|
+
var err = e.error;
|
|
137
|
+
send(buildEvent(err && err.name, (err && err.message) || e.message, err && err.stack));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
window.addEventListener("unhandledrejection", function (e) {
|
|
141
|
+
var r = e.reason || {};
|
|
142
|
+
var value = r.message || (typeof e.reason === "string" ? e.reason : "Unhandled promise rejection");
|
|
143
|
+
send(buildEvent(r.name || "UnhandledRejection", value, r.stack));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Manual capture API: window.Dispatch.captureException(error)
|
|
147
|
+
window.Dispatch = window.Dispatch || {};
|
|
148
|
+
window.Dispatch.captureException = function (err) {
|
|
149
|
+
err = err || {};
|
|
150
|
+
send(buildEvent(err.name, err.message, err.stack));
|
|
151
|
+
};
|
|
152
|
+
})();
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// Dispatch widget Stimulus controller.
|
|
2
|
+
// Registered automatically if Stimulus is present on the host page.
|
|
3
|
+
|
|
4
|
+
import { Controller } from "@hotwired/stimulus";
|
|
5
|
+
|
|
6
|
+
const CONSOLE_CAPTURE_MAX = 20;
|
|
7
|
+
const CLICK_PATH_MAX = 5;
|
|
8
|
+
const WIDGET_VERSION = "0.3.0";
|
|
9
|
+
const MAX_SCREENSHOTS = 5;
|
|
10
|
+
const MAX_SCREENSHOT_BYTES = 5 * 1024 * 1024;
|
|
11
|
+
const SCREENSHOT_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
|
12
|
+
|
|
13
|
+
// A short, human-readable label for an element the user clicked β used to build
|
|
14
|
+
// the "user path" (the last few things they clicked before reporting).
|
|
15
|
+
function describeClickTarget(node) {
|
|
16
|
+
let el = node && node.nodeType === 1 ? node : null;
|
|
17
|
+
if (!el) return null;
|
|
18
|
+
if (el.closest) {
|
|
19
|
+
el = el.closest("button, a, input, select, textarea, label, [role='button'], [role='link'], [data-action]") || el;
|
|
20
|
+
}
|
|
21
|
+
const tag = (el.tagName || "").toLowerCase();
|
|
22
|
+
const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 40);
|
|
23
|
+
const attr = el.getAttribute &&
|
|
24
|
+
(el.getAttribute("aria-label") || el.getAttribute("title") || el.getAttribute("name") || el.getAttribute("placeholder"));
|
|
25
|
+
const id = el.id ? "#" + el.id : "";
|
|
26
|
+
const label = text || attr;
|
|
27
|
+
if (label) return (tag + id + ' "' + label + '"').slice(0, 80);
|
|
28
|
+
const cls = (typeof el.className === "string" && el.className.trim()) ? "." + el.className.trim().split(/\s+/)[0] : "";
|
|
29
|
+
return (tag + id + cls).slice(0, 80) || tag || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default class DispatchWidgetController extends Controller {
|
|
33
|
+
static targets = [
|
|
34
|
+
"button", "modal", "description", "error", "submit", "toast",
|
|
35
|
+
"fileInput", "dropzone", "previews", "attachButton"
|
|
36
|
+
];
|
|
37
|
+
static values = {
|
|
38
|
+
endpoint: String,
|
|
39
|
+
apiKey: String,
|
|
40
|
+
user: { type: Object, default: {} },
|
|
41
|
+
metadata: { type: Object, default: {} },
|
|
42
|
+
severity: { type: String, default: "" },
|
|
43
|
+
captureConsole: Boolean,
|
|
44
|
+
captureClicks: Boolean
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
connect() {
|
|
48
|
+
this.files = [];
|
|
49
|
+
this.consoleEntries = [];
|
|
50
|
+
this.clickPath = [];
|
|
51
|
+
if (this.captureConsoleValue) {
|
|
52
|
+
const original = console.error;
|
|
53
|
+
console.error = (...args) => {
|
|
54
|
+
try {
|
|
55
|
+
this.consoleEntries.push(args.map(String).join(" "));
|
|
56
|
+
if (this.consoleEntries.length > CONSOLE_CAPTURE_MAX) {
|
|
57
|
+
this.consoleEntries.shift();
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
original.apply(console, args);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (this.captureClicksValue) {
|
|
65
|
+
this.boundTrackClick = this.trackClick.bind(this);
|
|
66
|
+
document.addEventListener("click", this.boundTrackClick, true);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
disconnect() {
|
|
71
|
+
if (this.boundTrackClick) {
|
|
72
|
+
document.removeEventListener("click", this.boundTrackClick, true);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Record the last few non-widget clicks as the user's path to the report.
|
|
77
|
+
trackClick(event) {
|
|
78
|
+
const target = event.target;
|
|
79
|
+
if (target && target.closest && target.closest("[data-controller~='dispatch-widget']")) return;
|
|
80
|
+
const label = describeClickTarget(target);
|
|
81
|
+
if (!label) return;
|
|
82
|
+
this.clickPath.push(label);
|
|
83
|
+
if (this.clickPath.length > CLICK_PATH_MAX) this.clickPath.shift();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
open() {
|
|
87
|
+
this.modalTarget.hidden = false;
|
|
88
|
+
this.descriptionTarget.focus();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
close() {
|
|
92
|
+
this.modalTarget.hidden = true;
|
|
93
|
+
this.errorTarget.style.display = "none";
|
|
94
|
+
this.descriptionTarget.value = "";
|
|
95
|
+
this.files = [];
|
|
96
|
+
this.renderPreviews();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- Screenshot input: file picker, drag & drop, paste --------------------
|
|
100
|
+
|
|
101
|
+
openFilePicker() {
|
|
102
|
+
this.fileInputTarget.click();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
filesPicked(event) {
|
|
106
|
+
this.addFiles(event.target.files);
|
|
107
|
+
event.target.value = ""; // allow re-picking the same file
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
dragover(event) {
|
|
111
|
+
event.preventDefault();
|
|
112
|
+
this.dropzoneTarget.style.borderColor = "#2563eb";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
dragleave() {
|
|
116
|
+
this.dropzoneTarget.style.borderColor = "#1f2937";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
drop(event) {
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
this.dropzoneTarget.style.borderColor = "#1f2937";
|
|
122
|
+
if (event.dataTransfer && event.dataTransfer.files.length) {
|
|
123
|
+
this.addFiles(event.dataTransfer.files);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
paste(event) {
|
|
128
|
+
const items = event.clipboardData && event.clipboardData.items;
|
|
129
|
+
if (!items) return;
|
|
130
|
+
const pasted = [];
|
|
131
|
+
for (const item of items) {
|
|
132
|
+
if (item.kind === "file") {
|
|
133
|
+
const file = item.getAsFile();
|
|
134
|
+
if (file) pasted.push(file);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (pasted.length) {
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
this.addFiles(pasted);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async addFiles(fileList) {
|
|
144
|
+
for (const file of Array.from(fileList)) {
|
|
145
|
+
if (this.files.length >= MAX_SCREENSHOTS) {
|
|
146
|
+
this.showError(`You can attach at most ${MAX_SCREENSHOTS} screenshots.`);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
if (!SCREENSHOT_TYPES.includes(file.type)) {
|
|
150
|
+
this.showError("Screenshots must be PNG, JPEG, GIF, or WebP images.");
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (file.size > MAX_SCREENSHOT_BYTES) {
|
|
154
|
+
this.showError("Each screenshot must be smaller than 5 MB.");
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const name = file.name || "screenshot.png";
|
|
158
|
+
if (this.files.some((f) => f.filename === name && f.size === file.size)) {
|
|
159
|
+
continue; // skip duplicates
|
|
160
|
+
}
|
|
161
|
+
const dataUrl = await this.readAsDataURL(file);
|
|
162
|
+
this.files.push({
|
|
163
|
+
filename: name,
|
|
164
|
+
content_type: file.type,
|
|
165
|
+
data: dataUrl.split(",")[1] || "",
|
|
166
|
+
size: file.size,
|
|
167
|
+
preview: dataUrl
|
|
168
|
+
});
|
|
169
|
+
this.renderPreviews();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
removeFile(index) {
|
|
174
|
+
this.files.splice(index, 1);
|
|
175
|
+
this.renderPreviews();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
renderPreviews() {
|
|
179
|
+
if (this.hasAttachButtonTarget) {
|
|
180
|
+
this.attachButtonTarget.textContent = `π Attach screenshots (${this.files.length}/${MAX_SCREENSHOTS})`;
|
|
181
|
+
}
|
|
182
|
+
if (!this.hasPreviewsTarget) return;
|
|
183
|
+
|
|
184
|
+
this.previewsTarget.replaceChildren();
|
|
185
|
+
this.files.forEach((file, index) => {
|
|
186
|
+
const wrapper = document.createElement("div");
|
|
187
|
+
wrapper.style.cssText = "position:relative;width:64px;height:64px;border:1px solid #1f2937;border-radius:6px;overflow:hidden;background:#0a0a0a;";
|
|
188
|
+
|
|
189
|
+
const img = document.createElement("img");
|
|
190
|
+
img.src = file.preview;
|
|
191
|
+
img.alt = file.filename;
|
|
192
|
+
img.style.cssText = "width:100%;height:100%;object-fit:cover;";
|
|
193
|
+
wrapper.appendChild(img);
|
|
194
|
+
|
|
195
|
+
const remove = document.createElement("button");
|
|
196
|
+
remove.type = "button";
|
|
197
|
+
remove.textContent = "Γ";
|
|
198
|
+
remove.setAttribute("aria-label", `Remove ${file.filename}`);
|
|
199
|
+
remove.style.cssText = "position:absolute;top:2px;right:2px;width:18px;height:18px;line-height:16px;padding:0;background:rgba(0,0,0,0.7);color:#fff;border:none;border-radius:9999px;cursor:pointer;font-size:13px;";
|
|
200
|
+
remove.addEventListener("click", () => this.removeFile(index));
|
|
201
|
+
wrapper.appendChild(remove);
|
|
202
|
+
|
|
203
|
+
this.previewsTarget.appendChild(wrapper);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
readAsDataURL(file) {
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
const reader = new FileReader();
|
|
210
|
+
reader.onload = () => resolve(reader.result);
|
|
211
|
+
reader.onerror = () => reject(reader.error);
|
|
212
|
+
reader.readAsDataURL(file);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- Submission -----------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
async submit() {
|
|
219
|
+
const description = this.descriptionTarget.value.trim();
|
|
220
|
+
if (description.length < 5) {
|
|
221
|
+
this.showError("Please add a little more detail (at least 5 characters).");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
this.submitTarget.disabled = true;
|
|
226
|
+
this.submitTarget.textContent = "Sendingβ¦";
|
|
227
|
+
|
|
228
|
+
const payload = {
|
|
229
|
+
ticket: {
|
|
230
|
+
description,
|
|
231
|
+
source: "widget",
|
|
232
|
+
severity: this.severityValue || null,
|
|
233
|
+
reporter: this.userValue && Object.keys(this.userValue).length ? this.userValue : null,
|
|
234
|
+
screenshots: this.files.map((f) => ({
|
|
235
|
+
filename: f.filename,
|
|
236
|
+
content_type: f.content_type,
|
|
237
|
+
data: f.data
|
|
238
|
+
})),
|
|
239
|
+
metadata: {
|
|
240
|
+
...this.metadataValue,
|
|
241
|
+
url: window.location.href,
|
|
242
|
+
user_agent: navigator.userAgent,
|
|
243
|
+
viewport: `${window.innerWidth}x${window.innerHeight}`,
|
|
244
|
+
referrer: document.referrer || null,
|
|
245
|
+
console_errors: this.consoleEntries.slice(),
|
|
246
|
+
user_path: this.clickPath.slice()
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const response = await fetch(this.endpointValue, {
|
|
253
|
+
method: "POST",
|
|
254
|
+
headers: {
|
|
255
|
+
"Content-Type": "application/json",
|
|
256
|
+
"Authorization": `Bearer ${this.apiKeyValue}`,
|
|
257
|
+
"Idempotency-Key": crypto.randomUUID(),
|
|
258
|
+
"X-Dispatch-Widget-Version": WIDGET_VERSION
|
|
259
|
+
},
|
|
260
|
+
body: JSON.stringify(payload),
|
|
261
|
+
mode: "cors"
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
if (!response.ok) {
|
|
265
|
+
const body = await response.text();
|
|
266
|
+
throw new Error(`Server returned ${response.status}: ${body.slice(0, 200)}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
this.close();
|
|
270
|
+
this.showToast();
|
|
271
|
+
} catch (error) {
|
|
272
|
+
this.showError(error.message || "Something went wrong sending your feedback.");
|
|
273
|
+
} finally {
|
|
274
|
+
this.submitTarget.disabled = false;
|
|
275
|
+
this.submitTarget.textContent = "Send";
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
showError(message) {
|
|
280
|
+
this.errorTarget.textContent = message;
|
|
281
|
+
this.errorTarget.style.display = "block";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
showToast() {
|
|
285
|
+
this.toastTarget.hidden = false;
|
|
286
|
+
setTimeout(() => { this.toastTarget.hidden = true; }, 3000);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Auto-register with the host application's Stimulus instance, if present.
|
|
291
|
+
if (window.Stimulus && typeof window.Stimulus.register === "function") {
|
|
292
|
+
window.Stimulus.register("dispatch-widget", DispatchWidgetController);
|
|
293
|
+
}
|