@realuptime/errors 0.4.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.
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/buffer.ts +52 -0
- package/client.ts +181 -0
- package/custom-scrub.ts +271 -0
- package/dist/buffer.d.ts +25 -0
- package/dist/buffer.js +42 -0
- package/dist/client.d.ts +53 -0
- package/dist/client.js +151 -0
- package/dist/custom-scrub.d.ts +48 -0
- package/dist/custom-scrub.js +262 -0
- package/dist/feedback.d.ts +173 -0
- package/dist/feedback.js +465 -0
- package/dist/index.d.ts +154 -0
- package/dist/index.js +516 -0
- package/dist/scrub.d.ts +60 -0
- package/dist/scrub.js +172 -0
- package/dist/sourcemaps.d.ts +142 -0
- package/dist/sourcemaps.js +262 -0
- package/dist/types.d.ts +250 -0
- package/dist/types.js +79 -0
- package/dist/vite.d.ts +76 -0
- package/dist/vite.js +75 -0
- package/dist/webpack.d.ts +38 -0
- package/dist/webpack.js +15 -0
- package/feedback.ts +617 -0
- package/index.ts +617 -0
- package/package.json +62 -0
- package/scrub.ts +181 -0
- package/sourcemaps.ts +318 -0
- package/types.ts +293 -0
- package/vite.ts +155 -0
- package/webpack.ts +46 -0
package/types.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Errors wire contract (docs/errors-plan.md, "The SDKs: two
|
|
3
|
+
* languages, one contract").
|
|
4
|
+
*
|
|
5
|
+
* DUPLICATED, not imported. Same convention as apps/agent/types.ts and for
|
|
6
|
+
* the same reason: this package ships into customer bundles and processes,
|
|
7
|
+
* so its dependency graph must stay auditable at a glance -- zero imports
|
|
8
|
+
* beyond the platform. The cost (a server-side rename does not break this
|
|
9
|
+
* build) is paid by wire-contract.test.ts, which pins every field name, and
|
|
10
|
+
* by the shared scrub vectors (scrub-vectors.json), which pin the scrub
|
|
11
|
+
* semantics across this SDK, the Python SDK, and the server's second net.
|
|
12
|
+
* The server-side mirror of these limits lives in
|
|
13
|
+
* packages/db/error-ingest.ts.
|
|
14
|
+
*
|
|
15
|
+
* ## v2 (REA-182): identity, tags, custom context, device metadata
|
|
16
|
+
*
|
|
17
|
+
* STRICTLY ADDITIVE, and that word is load-bearing rather than decorative.
|
|
18
|
+
* Every v1 field keeps its name, its type and its meaning; every v2 field is
|
|
19
|
+
* OPTIONAL and absent from a v1 payload. A v1 batch produced by an SDK that
|
|
20
|
+
* predates this file still ingests byte-for-byte unchanged, which
|
|
21
|
+
* route.test.ts pins with a frozen golden v1 body.
|
|
22
|
+
*
|
|
23
|
+
* **There is deliberately no `version` field on the wire, and that is a
|
|
24
|
+
* decision, not an omission.** A version discriminant would have to be
|
|
25
|
+
* REQUIRED to be worth reading, and a required field is by construction not
|
|
26
|
+
* additive: every already-deployed SDK in a customer's process omits it, so
|
|
27
|
+
* the server would have to treat "absent" as v1 anyway -- which is exactly
|
|
28
|
+
* what optional-field presence already tells it, with no field to keep in
|
|
29
|
+
* sync. The `sdk` string ("realuptime-errors-js/0.3.0") remains the honest
|
|
30
|
+
* record of what produced a payload. A future change that is NOT additive
|
|
31
|
+
* (a removed field, a changed meaning) gets a versioned PATH, the way the
|
|
32
|
+
* fingerprint algorithm gets a version rather than mutating history.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** One stack frame, post-scrub. `inApp` is the SDK's best-effort guess
|
|
36
|
+
* (Node: not under node_modules and not an internal frame); the server
|
|
37
|
+
* trusts it for display and for fingerprinting. */
|
|
38
|
+
export interface WireFrame {
|
|
39
|
+
file: string;
|
|
40
|
+
function: string | null;
|
|
41
|
+
line: number | null;
|
|
42
|
+
inApp: boolean;
|
|
43
|
+
/** 1-based column when the runtime provided one (Phase 3). Source-map
|
|
44
|
+
* symbolication needs it; a frame without one renders raw server-side
|
|
45
|
+
* with a stated reason, never silently pretty. */
|
|
46
|
+
column?: number | null;
|
|
47
|
+
/** Source context, captured client-side where the SDK can read source
|
|
48
|
+
* (the Python SDK); pattern-scrubbed before serialization. */
|
|
49
|
+
contextLine?: string | null;
|
|
50
|
+
preContext?: string[];
|
|
51
|
+
postContext?: string[];
|
|
52
|
+
/** v2 (REA-182): this frame's local variables, stringified.
|
|
53
|
+
*
|
|
54
|
+
* OFF BY DEFAULT in both SDKs and hard-capped when on
|
|
55
|
+
* (MAX_LOCAL_VARS_PER_FRAME entries, MAX_LOCAL_VAR_LENGTH characters
|
|
56
|
+
* each), because locals are the single leakiest thing an error tracker
|
|
57
|
+
* can capture: the variable holding the password is, definitionally, in
|
|
58
|
+
* scope at the frame that failed to use it. Values pass the pattern scrub
|
|
59
|
+
* like every other captured string, but the pattern scrub is a net, not a
|
|
60
|
+
* guarantee, which is why the switch defaults to off rather than to
|
|
61
|
+
* "trust the regexes".
|
|
62
|
+
*
|
|
63
|
+
* Captured by the Python SDK only, the same asymmetry `contextLine`
|
|
64
|
+
* already has: a JS stack is a STRING, and the runtime hands the SDK no
|
|
65
|
+
* access to the frames behind it. The field is in the contract on both
|
|
66
|
+
* sides so the scrub, the storage and the UI are one implementation. */
|
|
67
|
+
vars?: Record<string, string> | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** v2 (REA-182): who this happened to.
|
|
71
|
+
*
|
|
72
|
+
* `id` is the field the product is built around: an opaque account or user
|
|
73
|
+
* identifier is what makes "how many users hit this" answerable, and it is
|
|
74
|
+
* the customer's own key space, not a person's contact details. It rides
|
|
75
|
+
* the wire as-is (pattern-scrubbed like any other string).
|
|
76
|
+
*
|
|
77
|
+
* `email` and `username` are PERSONAL DATA and are `[scrubbed]` by default,
|
|
78
|
+
* client-side, before serialization. A customer who genuinely needs them
|
|
79
|
+
* opts each one back in BY NAME in SDK config (`allowFields: ["user.email"]`)
|
|
80
|
+
* -- the same per-field, in-code, never-a-global-switch shape the removed
|
|
81
|
+
* headers use, and for the same reason: a one-switch opt-out is how a
|
|
82
|
+
* default dies. There is no server-side way to turn this on, deliberately:
|
|
83
|
+
* a default that a dashboard toggle can disable is a default that leaks
|
|
84
|
+
* before anyone notices. */
|
|
85
|
+
export interface WireUser {
|
|
86
|
+
id?: string | null;
|
|
87
|
+
email?: string | null;
|
|
88
|
+
username?: string | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** v2 (REA-182): where it ran. Runtime and platform facts the SDK reads out
|
|
92
|
+
* of its own process, never out of the request.
|
|
93
|
+
*
|
|
94
|
+
* Deliberately NOT here: hostname, local IP, container id, or anything else
|
|
95
|
+
* that identifies a machine rather than a runtime. Those are operational
|
|
96
|
+
* identifiers with real re-identification value and no debugging value that
|
|
97
|
+
* `release` + `environment` does not already carry. */
|
|
98
|
+
export interface WireDevice {
|
|
99
|
+
/** "node", "python", "browser". */
|
|
100
|
+
runtime?: string;
|
|
101
|
+
/** e.g. "22.4.0", "3.12.1". */
|
|
102
|
+
runtimeVersion?: string;
|
|
103
|
+
/** e.g. "linux", "darwin", "win32". */
|
|
104
|
+
platform?: string;
|
|
105
|
+
/** e.g. "x64", "arm64". */
|
|
106
|
+
arch?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** One breadcrumb (Phase 3): a scrubbed trail entry recorded before the
|
|
110
|
+
* event. Newest last on the wire. */
|
|
111
|
+
export interface WireBreadcrumb {
|
|
112
|
+
/** ISO 8601, the customer clock; display-only. */
|
|
113
|
+
timestamp: string | null;
|
|
114
|
+
category: string | null;
|
|
115
|
+
message: string;
|
|
116
|
+
data: Record<string, string> | null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Minimal request context: method, path, status by default. Headers appear
|
|
120
|
+
* only for fields the integrator captured deliberately, and the scrub-by-
|
|
121
|
+
* default rules in scrub.ts have already run over everything here before it
|
|
122
|
+
* is serialized. */
|
|
123
|
+
export interface WireRequest {
|
|
124
|
+
method?: string;
|
|
125
|
+
path?: string;
|
|
126
|
+
status?: number;
|
|
127
|
+
headers?: Record<string, string>;
|
|
128
|
+
/** v2 (REA-182): the matched ROUTE TEMPLATE, e.g. "/orders/:id", as
|
|
129
|
+
* distinct from the concrete path "/orders/4242". This is the one extra
|
|
130
|
+
* thing a framework adapter genuinely knows and cannot be guessed from
|
|
131
|
+
* the path, it is low-cardinality (it groups), and it carries no
|
|
132
|
+
* user-supplied bytes at all. Adapters forward it and nothing else new:
|
|
133
|
+
* never headers, never cookies, never bodies. Pattern-scrubbed anyway,
|
|
134
|
+
* because a route template is still a string a customer wrote. */
|
|
135
|
+
route?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface WireEvent {
|
|
139
|
+
/** ISO 8601, the customer process's clock. The server bounds it and never
|
|
140
|
+
* uses it for quota. */
|
|
141
|
+
occurredAt: string;
|
|
142
|
+
message: string;
|
|
143
|
+
exceptionType: string | null;
|
|
144
|
+
release: string | null;
|
|
145
|
+
environment: string | null;
|
|
146
|
+
frames: WireFrame[] | null;
|
|
147
|
+
request: WireRequest | null;
|
|
148
|
+
/** Custom grouping override (`fingerprint: [...]`), the escape hatch for
|
|
149
|
+
* the cases automatic grouping gets wrong. Null means "group for me". */
|
|
150
|
+
fingerprint: string[] | null;
|
|
151
|
+
/** The breadcrumb trail preceding this event, newest last, at most
|
|
152
|
+
* MAX_BREADCRUMBS_PER_EVENT (Phase 3). Null when none were recorded. */
|
|
153
|
+
breadcrumbs: WireBreadcrumb[] | null;
|
|
154
|
+
/** How many older breadcrumbs the bounded client-side ring had evicted
|
|
155
|
+
* when this event was built: the count of what the trail is NOT showing,
|
|
156
|
+
* reported so the server can render "showing the last N; M older
|
|
157
|
+
* dropped" instead of a quietly truncated trail. */
|
|
158
|
+
breadcrumbsDropped: number;
|
|
159
|
+
|
|
160
|
+
// ── v2 (REA-182), all optional and all absent from a v1 payload ───────
|
|
161
|
+
|
|
162
|
+
/** Who this happened to. See WireUser: id rides as-is, email and username
|
|
163
|
+
* are scrubbed unless opted in per field. */
|
|
164
|
+
user?: WireUser | null;
|
|
165
|
+
/** Short, low-cardinality labels the customer sets (`setTag`), the things
|
|
166
|
+
* an issues list can filter on: "tenant", "plan", "feature-flag". Values
|
|
167
|
+
* pass the pattern scrub. At most MAX_TAGS_PER_EVENT. */
|
|
168
|
+
tags?: Record<string, string> | null;
|
|
169
|
+
/** Free-form custom context the customer sets (`setContext`): the
|
|
170
|
+
* application state that makes an issue diagnosable and that no schema of
|
|
171
|
+
* ours could have anticipated. Values pass the pattern scrub. At most
|
|
172
|
+
* MAX_CONTEXT_ENTRIES. */
|
|
173
|
+
context?: Record<string, string> | null;
|
|
174
|
+
/** Runtime/platform metadata the SDK reads from its own process. */
|
|
175
|
+
device?: WireDevice | null;
|
|
176
|
+
/** REA-257: a client-minted UUID for this event, present ONLY when the
|
|
177
|
+
* integration opted into user feedback (`init({ feedback: {...} })`). It
|
|
178
|
+
* is what a later `POST /api/errors/v1/feedback/<key>` names to attach a
|
|
179
|
+
* user's report to this exact occurrence. An integration that never
|
|
180
|
+
* enables feedback keeps emitting the id-less payload it always did. */
|
|
181
|
+
eventId?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface WireBatch {
|
|
185
|
+
/** e.g. "realuptime-errors-js/0.1.0" */
|
|
186
|
+
sdk: string;
|
|
187
|
+
/** Events this SDK dropped client-side (bounded buffer overflow) since the
|
|
188
|
+
* last successful delivery. Relayed so even client-side drops reach the
|
|
189
|
+
* product's visible drop counters -- never omitted, never reset without
|
|
190
|
+
* being reported. */
|
|
191
|
+
droppedClient: number;
|
|
192
|
+
events: WireEvent[];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Success body. `accepted` + `droppedOverQuota` always sum to the batch's
|
|
196
|
+
* event count; `overQuota: true` with `resetsAt` tells the SDK to stop
|
|
197
|
+
* sending until the window resets rather than burning the customer's cycles
|
|
198
|
+
* on refusals. */
|
|
199
|
+
export interface WireIngestResult {
|
|
200
|
+
accepted: number;
|
|
201
|
+
droppedOverQuota: number;
|
|
202
|
+
overQuota: boolean;
|
|
203
|
+
quota: { used: number; limit: number; resetsAt: string };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Machine-readable refusal reasons the SDK understands. Anything else is
|
|
207
|
+
* treated as transient.
|
|
208
|
+
*
|
|
209
|
+
* `oversize_context` (v2, RU-4009) is a 400 like `malformed`, and both SDKs
|
|
210
|
+
* already handle every 400 the same way -- drop the batch, log once, never
|
|
211
|
+
* retry -- so it needs no new client branch. It is a SEPARATE reason string
|
|
212
|
+
* because "your event carried 500 tags" and "your event was not valid JSON"
|
|
213
|
+
* are different bugs in the customer's integration, and telling them apart
|
|
214
|
+
* costs one string. */
|
|
215
|
+
export type WireRefusalReason =
|
|
216
|
+
| "over_quota"
|
|
217
|
+
| "rate_limited"
|
|
218
|
+
| "key_revoked"
|
|
219
|
+
| "malformed"
|
|
220
|
+
| "oversize_context";
|
|
221
|
+
|
|
222
|
+
// Mirrored by MAX_ERROR_EVENTS_PER_BATCH etc. in packages/db/error-ingest.ts
|
|
223
|
+
// and pinned by wire-contract.test.ts on both sides.
|
|
224
|
+
export const MAX_EVENTS_PER_BATCH = 50;
|
|
225
|
+
export const MAX_MESSAGE_LENGTH = 4000;
|
|
226
|
+
export const MAX_FRAMES_PER_EVENT = 50;
|
|
227
|
+
export const MAX_STRING_LENGTH = 512;
|
|
228
|
+
export const MAX_BREADCRUMBS_PER_EVENT = 20;
|
|
229
|
+
export const MAX_BREADCRUMB_DATA_ENTRIES = 10;
|
|
230
|
+
|
|
231
|
+
// ── v2 (REA-182) caps ───────────────────────────────────────────────────
|
|
232
|
+
//
|
|
233
|
+
// Two tiers, deliberately:
|
|
234
|
+
//
|
|
235
|
+
// SOFT caps (the constants below) are what an honest integration lives
|
|
236
|
+
// inside. Going past one is not an error: the server keeps the first N
|
|
237
|
+
// entries, COUNTS what it dropped, and stores the count so the issue
|
|
238
|
+
// detail can say "3 context entries were dropped (size cap)". Truncating
|
|
239
|
+
// silently is the exact lie this product was founded against, so the
|
|
240
|
+
// count is first-class data rather than a log line.
|
|
241
|
+
//
|
|
242
|
+
// The HARD bound (HARD_MAX_MAP_ENTRIES) is where a payload stops being an
|
|
243
|
+
// over-eager integration and starts being a bug or an attack; that one
|
|
244
|
+
// REFUSES the batch with RU-4009 rather than quietly keeping 20 of 5,000.
|
|
245
|
+
|
|
246
|
+
/** Tags per event kept; extras are dropped and counted. */
|
|
247
|
+
export const MAX_TAGS_PER_EVENT = 20;
|
|
248
|
+
/** Custom-context entries per event kept; extras are dropped and counted. */
|
|
249
|
+
export const MAX_CONTEXT_ENTRIES = 20;
|
|
250
|
+
/** Tag and context KEY length. Shorter than MAX_STRING_LENGTH because a key
|
|
251
|
+
* is a label, not a payload; a 512-character key is a value in disguise. */
|
|
252
|
+
export const MAX_CONTEXT_KEY_LENGTH = 64;
|
|
253
|
+
/** Local variables kept per frame when the (off-by-default) capture is on. */
|
|
254
|
+
export const MAX_LOCAL_VARS_PER_FRAME = 20;
|
|
255
|
+
/** Per-value cap for a local variable, tighter than MAX_STRING_LENGTH: a
|
|
256
|
+
* local is captured for its shape, not its contents. */
|
|
257
|
+
export const MAX_LOCAL_VAR_LENGTH = 256;
|
|
258
|
+
/** Combined serialized budget for user + tags + context + device on ONE
|
|
259
|
+
* event, enforced server-side inside the ingest transaction. Entries past
|
|
260
|
+
* it are dropped and counted, same honesty rule as the soft caps. */
|
|
261
|
+
export const MAX_CONTEXT_BYTES = 4096;
|
|
262
|
+
/** The refusal boundary: more entries than this in any one map is a bug in
|
|
263
|
+
* the caller, not an over-eager integration. */
|
|
264
|
+
export const HARD_MAX_MAP_ENTRIES = 200;
|
|
265
|
+
|
|
266
|
+
// ── REA-257: user feedback (the report dialog) ──────────────────────────
|
|
267
|
+
//
|
|
268
|
+
// Opt-in, customer-configured, and deliberately a SEPARATE wire message from
|
|
269
|
+
// the event batch: a user's own words about what they were doing are
|
|
270
|
+
// personal data the customer chose to collect, so they travel only when the
|
|
271
|
+
// customer turned the feature on, and they are scrubbed (the same pattern
|
|
272
|
+
// rules) before they leave the page. Mirrored by packages/db/error-feedback.ts
|
|
273
|
+
// and pinned by wire-contract.test.ts.
|
|
274
|
+
|
|
275
|
+
/** `POST /api/errors/v1/feedback/<key>` body. */
|
|
276
|
+
export interface WireFeedback {
|
|
277
|
+
/** The client-minted event id the report attaches to (WireEvent.eventId). */
|
|
278
|
+
eventId: string;
|
|
279
|
+
/** What the user typed. Scrubbed client-side before serialization. */
|
|
280
|
+
comments: string;
|
|
281
|
+
/** Optional, only sent when the dialog showed the field and the user
|
|
282
|
+
* filled it. Scrubbed by the same pattern rules. */
|
|
283
|
+
name?: string;
|
|
284
|
+
email?: string;
|
|
285
|
+
/** e.g. "realuptime-errors-js/0.4.0" */
|
|
286
|
+
sdk: string;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export const MAX_FEEDBACK_COMMENTS_LENGTH = 4000;
|
|
290
|
+
export const MAX_FEEDBACK_NAME_LENGTH = 200;
|
|
291
|
+
export const MAX_FEEDBACK_EMAIL_LENGTH = 254;
|
|
292
|
+
/** Body size ceiling the server enforces on the feedback endpoint. */
|
|
293
|
+
export const MAX_FEEDBACK_BODY_BYTES = 16_384;
|
package/vite.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
collectSourcemapFiles,
|
|
4
|
+
formatProgressLine,
|
|
5
|
+
preflightSourcemapFiles,
|
|
6
|
+
resolveSourcemapUploadUrl,
|
|
7
|
+
type SourcemapFile,
|
|
8
|
+
uploadSourcemaps,
|
|
9
|
+
type UploadSummary,
|
|
10
|
+
} from "./sourcemaps.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Vite plugin (REA-254): uploads the `.map` files a production build wrote
|
|
14
|
+
* to its outDir, on `closeBundle`, to the project's source-map endpoint.
|
|
15
|
+
*
|
|
16
|
+
* // vite.config.ts
|
|
17
|
+
* import { realuptimeSourcemaps } from "@realuptime/errors/vite";
|
|
18
|
+
* export default defineConfig({
|
|
19
|
+
* build: { sourcemap: true },
|
|
20
|
+
* plugins: [
|
|
21
|
+
* realuptimeSourcemaps({
|
|
22
|
+
* release: process.env.RELEASE ?? process.env.GITHUB_SHA!,
|
|
23
|
+
* dsn: process.env.REALUPTIME_ERRORS_DSN,
|
|
24
|
+
* }),
|
|
25
|
+
* ],
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* It does not import "vite": the hook shapes are matched structurally, so
|
|
29
|
+
* this stays a zero-dependency file in a zero-dependency package (the same
|
|
30
|
+
* rule the framework adapters follow). It never changes the build output:
|
|
31
|
+
* no map stripping, no injected code, no release constant, nothing. It reads
|
|
32
|
+
* the maps Vite already wrote and posts them.
|
|
33
|
+
*
|
|
34
|
+
* Failure policy: an upload failure is logged loudly with the server's own
|
|
35
|
+
* message and the build still succeeds, because a deploy pipeline that
|
|
36
|
+
* cannot reach realuptime should not also fail to ship; set
|
|
37
|
+
* `failOnError: true` to make the build fail instead. Either way the
|
|
38
|
+
* failure is one clear line, never silence.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
export interface SourcemapPluginOptions {
|
|
42
|
+
/** The release string the app reports at runtime (`init({ release })`).
|
|
43
|
+
* The maps are matched to events by this exact string. */
|
|
44
|
+
release: string;
|
|
45
|
+
/** The project DSN; `REALUPTIME_ERRORS_DSN` is read when omitted. */
|
|
46
|
+
dsn?: string;
|
|
47
|
+
/** A bare ingest key (`REALUPTIME_ERRORS_INGEST_KEY` when omitted), used
|
|
48
|
+
* with `origin` when no DSN is set. */
|
|
49
|
+
ingestKey?: string;
|
|
50
|
+
origin?: string;
|
|
51
|
+
/** Prefix artifact names with the public path the bundle is served from
|
|
52
|
+
* (e.g. `~/assets` or `static/js`), when the outDir layout does not match
|
|
53
|
+
* the URL path. Matching is by path suffix, so it is rarely needed. */
|
|
54
|
+
urlPrefix?: string;
|
|
55
|
+
/** Override the directory to collect from; defaults to the resolved
|
|
56
|
+
* build outDir. */
|
|
57
|
+
dir?: string;
|
|
58
|
+
/** Skip the upload without failing (e.g. local builds): when false, the
|
|
59
|
+
* plugin logs one line and does nothing. Default: upload whenever a
|
|
60
|
+
* credential is present. */
|
|
61
|
+
enabled?: boolean;
|
|
62
|
+
failOnError?: boolean;
|
|
63
|
+
/** Test seams. */
|
|
64
|
+
fetchImpl?: typeof fetch;
|
|
65
|
+
log?: (line: string) => void;
|
|
66
|
+
env?: Record<string, string | undefined>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The slice of Vite's ResolvedConfig this plugin reads. */
|
|
70
|
+
interface ResolvedConfigLike {
|
|
71
|
+
root?: string;
|
|
72
|
+
build?: { outDir?: string };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface SourcemapPlugin {
|
|
76
|
+
name: string;
|
|
77
|
+
apply: "build";
|
|
78
|
+
enforce: "post";
|
|
79
|
+
configResolved(config: ResolvedConfigLike): void;
|
|
80
|
+
closeBundle(): Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The shared run both plugins call: collect, pre-flight, upload, report.
|
|
84
|
+
* Returns the summary on success, null when skipped or failed (already
|
|
85
|
+
* logged); throws only when `failOnError` is set. */
|
|
86
|
+
export async function runSourcemapUpload(
|
|
87
|
+
options: SourcemapPluginOptions,
|
|
88
|
+
dir: string,
|
|
89
|
+
label: string,
|
|
90
|
+
): Promise<UploadSummary | null> {
|
|
91
|
+
const log = options.log ?? ((line: string) => console.error(line));
|
|
92
|
+
const env = options.env ?? process.env;
|
|
93
|
+
const prefix = `[realuptime-errors ${label}]`;
|
|
94
|
+
const fail = (message: string): null => {
|
|
95
|
+
if (options.failOnError) throw new Error(`${prefix} ${message}`);
|
|
96
|
+
log(`${prefix} ${message}`);
|
|
97
|
+
return null;
|
|
98
|
+
};
|
|
99
|
+
if (options.enabled === false) {
|
|
100
|
+
log(`${prefix} disabled; no source maps uploaded.`);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const dsn = options.dsn ?? env.REALUPTIME_ERRORS_DSN;
|
|
104
|
+
const ingestKey = options.ingestKey ?? env.REALUPTIME_ERRORS_INGEST_KEY;
|
|
105
|
+
if (!dsn && !ingestKey) {
|
|
106
|
+
log(`${prefix} no REALUPTIME_ERRORS_DSN or ingest key set; no source maps uploaded for ${options.release}.`);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
if (!options.release) return fail("release is required; no source maps uploaded.");
|
|
110
|
+
let url: string;
|
|
111
|
+
try {
|
|
112
|
+
url = resolveSourcemapUploadUrl({ dsn, ingestKey, origin: options.origin });
|
|
113
|
+
} catch (cause) {
|
|
114
|
+
return fail(cause instanceof Error ? cause.message : String(cause));
|
|
115
|
+
}
|
|
116
|
+
const { files, duplicates, missing } = collectSourcemapFiles([dir], { urlPrefix: options.urlPrefix });
|
|
117
|
+
if (missing.length > 0) return fail(`${dir} does not exist; no source maps uploaded.`);
|
|
118
|
+
const problem = preflightSourcemapFiles(files);
|
|
119
|
+
if (problem) return fail(problem);
|
|
120
|
+
for (const name of duplicates) log(`${prefix} duplicate artifact name ${name}; the first occurrence was kept.`);
|
|
121
|
+
try {
|
|
122
|
+
const summary = await uploadSourcemaps({
|
|
123
|
+
url,
|
|
124
|
+
release: options.release,
|
|
125
|
+
files,
|
|
126
|
+
fetchImpl: options.fetchImpl,
|
|
127
|
+
onProgress: (progress) => log(`${prefix} ${formatProgressLine(progress)}`),
|
|
128
|
+
});
|
|
129
|
+
log(`${prefix} uploaded ${summary.uploaded} source map${summary.uploaded === 1 ? "" : "s"} for release ${summary.release} (stored ${summary.stored}, replaced ${summary.replaced}).`);
|
|
130
|
+
return summary;
|
|
131
|
+
} catch (cause) {
|
|
132
|
+
return fail(cause instanceof Error ? cause.message : String(cause));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function realuptimeSourcemaps(options: SourcemapPluginOptions): SourcemapPlugin {
|
|
137
|
+
let outDir = options.dir ?? "dist";
|
|
138
|
+
return {
|
|
139
|
+
name: "realuptime-errors-sourcemaps",
|
|
140
|
+
apply: "build",
|
|
141
|
+
enforce: "post",
|
|
142
|
+
configResolved(config) {
|
|
143
|
+
if (options.dir) {
|
|
144
|
+
outDir = resolve(config.root ?? process.cwd(), options.dir);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
outDir = resolve(config.root ?? process.cwd(), config.build?.outDir ?? "dist");
|
|
148
|
+
},
|
|
149
|
+
async closeBundle() {
|
|
150
|
+
await runSourcemapUpload(options, outDir, "vite");
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type { SourcemapFile, UploadSummary };
|
package/webpack.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { runSourcemapUpload, type SourcemapPluginOptions } from "./vite.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* webpack plugin (REA-254), same core as the Vite plugin: after webpack
|
|
6
|
+
* emits its assets, the `.map` files in `output.path` are uploaded to the
|
|
7
|
+
* project's source-map endpoint for the given release.
|
|
8
|
+
*
|
|
9
|
+
* // webpack.config.js
|
|
10
|
+
* const { RealuptimeSourcemapsPlugin } = require("@realuptime/errors/webpack");
|
|
11
|
+
* module.exports = {
|
|
12
|
+
* devtool: "source-map",
|
|
13
|
+
* plugins: [new RealuptimeSourcemapsPlugin({ release: process.env.GITHUB_SHA })],
|
|
14
|
+
* };
|
|
15
|
+
*
|
|
16
|
+
* Works with webpack 4 and 5 and with Rspack: it only uses
|
|
17
|
+
* `compiler.hooks.afterEmit.tapPromise` and `compiler.options.output.path`,
|
|
18
|
+
* both matched structurally (no "webpack" import, zero dependencies). It
|
|
19
|
+
* never changes the emitted assets. Failure policy is the Vite plugin's:
|
|
20
|
+
* loud log, build still succeeds, unless `failOnError: true`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
interface CompilerLike {
|
|
24
|
+
options?: { output?: { path?: string }; context?: string };
|
|
25
|
+
hooks: {
|
|
26
|
+
afterEmit: { tapPromise(name: string, fn: () => Promise<void>): void };
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class RealuptimeSourcemapsPlugin {
|
|
31
|
+
private readonly options: SourcemapPluginOptions;
|
|
32
|
+
|
|
33
|
+
constructor(options: SourcemapPluginOptions) {
|
|
34
|
+
this.options = options;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
apply(compiler: CompilerLike): void {
|
|
38
|
+
compiler.hooks.afterEmit.tapPromise("RealuptimeSourcemapsPlugin", async () => {
|
|
39
|
+
const context = compiler.options?.context ?? process.cwd();
|
|
40
|
+
const dir = this.options.dir ? resolve(context, this.options.dir) : (compiler.options?.output?.path ?? resolve(context, "dist"));
|
|
41
|
+
await runSourcemapUpload(this.options, dir, "webpack");
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type { SourcemapPluginOptions };
|