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 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
+ }