@weatherboard/errors 0.1.0 → 0.2.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/README.md CHANGED
@@ -8,8 +8,10 @@ Entry points:
8
8
  - `./next/client`: deferred browser initialization and `onRouterTransitionStart`. Buffers early errors and rejections, loading immediately on the first error; otherwise waits for load and idle. Breadcrumbs before SDK initialization are unavailable.
9
9
  - `./next/global-error`: `GlobalError` reports render failures and renders its required `children`; the product supplies the fallback document and design.
10
10
  - `./client`: `reportHandled(error, { where, extra })` for catches that show a banner or toast. Fingerprints use `where` and message. Never throws; before the deferred browser SDK loads, queues the error and its context and requests the SDK immediately. With no configured loader or a disabled SDK, it is a no-op. The `where` tag identifies the catch site.
11
+ - `./http`: `refuse(status, message, { kind, where, context?, body?, headers? })` returns `{ error: message, ...body }`. Expected refusals add a breadcrumb; invariant refusals capture a grouped warning. `fail(message, cause, { where, status = 500, context?, body?, headers? })` captures the original exception and returns only the supplied public message/body. Both tolerate reporting failures and skip telemetry before SDK initialization. Context redacts sensitive keys, bearer strings, known token formats and email addresses.
11
12
  - `./noise`: shared patterns with their justifying issue ids.
12
- - `./verify`: `verifyErrors(appDirectory)` registers the product wiring test.
13
+ - `./worker`: `withWorkerErrors`, `withDurableObjectErrors` and Worker `reportHandled`.
14
+ - `./verify`: `verifyErrors(appDirectory)` or `verifyWorkerWiring(workerDirectory)` registers the product wiring test.
13
15
 
14
16
  The five configuration variables are `SENTRY_DSN` (server), `PUBLIC_SENTRY_DSN` (browser), `SENTRY_ORG`, `SENTRY_PROJECT`, and `SENTRY_AUTH_TOKEN` (secret, build only). The wrapper inlines only the public DSN. Reporting requires a DSN and `VERCEL_ENV=production` or `preview`; development and unset environments do not initialize. Release comes from `VERCEL_GIT_COMMIT_SHA`, never `NODE_ENV`. Tracing is off and default PII is disabled.
15
17
 
@@ -20,4 +22,39 @@ Wire a Next app in four files:
20
22
  3. In `instrumentation-client.ts`: export `{ onRouterTransitionStart }` from `@weatherboard/errors/next/client`.
21
23
  4. In the app's global error boundary, mark it `"use client"`, import `{ GlobalError }` from `@weatherboard/errors/next/global-error`, and render `<GlobalError error={error}><html><body>your fallback</body></html></GlobalError>`.
22
24
 
23
- Add a node:test file calling `verifyErrors` with the app directory. HTTP helpers are reserved for a future `./http` entry; Workers and React Native are outside this version.
25
+ Add a node:test file calling `verifyErrors` with the app directory. React Native is outside this version. Never pass a private cause message as the public HTTP message. Extra body fields retain their existing wire shape; they are not telemetry context.
26
+
27
+ ## Cloudflare Workers
28
+
29
+ Import from `@weatherboard/errors/worker`. Wrap the default handler with
30
+ `withWorkerErrors(handler)`. Export each Durable Object as
31
+ `const MyObject = withDurableObjectErrors(MyObjectImplementation)` and an instance
32
+ type alias when bindings reference that type. Built-in handlers, alarms, WebSocket
33
+ handlers and prototype RPC methods are instrumented by `@sentry/cloudflare`.
34
+ `verifyWorkerWiring(dir)` checks the defaults, DO classes and direct SDK imports.
35
+
36
+ Optional environment fields (extend `WorkerErrorEnv` in the Worker's typed Env):
37
+
38
+ | Field | Purpose |
39
+ | --- | --- |
40
+ | `SENTRY_DSN` | Worker secret; absent means the original invocation/construction path runs without Sentry |
41
+ | `SENTRY_ENVIRONMENT` | Explicit reporting environment |
42
+ | `ENVIRONMENT` | Existing per-environment Worker var, used when the explicit field is absent |
43
+ | `SENTRY_RELEASE` | Git SHA recorded by the deploy; takes precedence over version metadata |
44
+ | `CF_VERSION_METADATA` | Cloudflare `version_metadata` binding; when `SENTRY_RELEASE` is absent, release uses `tag`, then `id` if untagged (a platform version ID, not a git SHA) |
45
+
46
+ A Worker deployed per environment reads its own environment var. A Worker shared
47
+ by every environment must take the decision from its caller through a trusted
48
+ mapping, or treat absence as non-production; never infer production from the
49
+ shared Worker's deployment. Unset environment becomes `development`. Reporting is
50
+ enabled by a DSN; development is not promoted to production or silently discarded.
51
+ No DSN leaves the handler's arguments, receiver, result and thrown errors unchanged.
52
+ The wrapper selects the original handler at invocation time because Worker secrets
53
+ are unavailable at module evaluation. DO construction similarly bypasses the SDK.
54
+
55
+ Tracing is disabled (`tracesSampleRate: 0`), default PII is disabled
56
+ (`sendDefaultPii: false`), and the SDK flushes via `waitUntil`; responses do not wait
57
+ for reporting. In catches that recover, keep the existing log and call
58
+ `reportHandled(error, { where, extra })` inside the instrumented invocation. It
59
+ fingerprints by `where` and message and never throws. Only send scrubbed errors and
60
+ safe extras: default PII settings do not scrub application-supplied exception text.
package/http.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ type Options = {
2
+ where: string;
3
+ context?: Record<string, unknown>;
4
+ body?: Record<string, unknown>;
5
+ headers?: HeadersInit;
6
+ };
7
+ /** A valid refusal is a breadcrumb; an impossible refusal is a grouped warning. */
8
+ export declare function refuse(status: number, message: string, options: Options & {
9
+ kind: "expected" | "invariant";
10
+ }): Response;
11
+ /** Report the original cause; only the public message and body are sent to the caller. */
12
+ export declare function fail(message: string, cause: unknown, options: Options & {
13
+ status?: number;
14
+ }): Response;
15
+ export {};
package/http.js ADDED
@@ -0,0 +1,60 @@
1
+ import * as SentryModule from "@sentry/nextjs";
2
+ const Sentry = SentryModule.default ?? SentryModule;
3
+ function scrub(value, seen = /* @__PURE__ */ new WeakSet(), depth = 0) {
4
+ if (typeof value === "string") return value.replace(/\bBearer\s+\S+/gi, "[redacted]").replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[redacted]").replace(/\b(?:gh[pousr]_[\w]+|github_pat_[\w]+|sk-[\w-]+|eyJ[\w-]+\.[\w-]+\.[\w-]+)\b/g, "[redacted]");
5
+ if (value === null || typeof value !== "object") return value;
6
+ if (depth >= 8 || seen.has(value)) return "[redacted]";
7
+ seen.add(value);
8
+ if (Array.isArray(value)) return value.map((item) => scrub(item, seen, depth + 1));
9
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
10
+ key,
11
+ /token|secret|password|authorization|cookie|email|api.?key|credential/i.test(key) ? "[redacted]" : scrub(item, seen, depth + 1)
12
+ ]));
13
+ }
14
+ function template(message) {
15
+ return String(scrub(message)).replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/gi, "<id>").replace(/\b[a-z]+_[a-z0-9-]+\b/gi, "<id>").replace(/\b[0-9a-f]{8,}\b/gi, "<id>").replace(/\d+/g, "<n>");
16
+ }
17
+ function response(message, status, options) {
18
+ try {
19
+ return Response.json({ error: message, ...options.body }, { status, headers: options.headers });
20
+ } catch {
21
+ return Response.json({ error: message }, { status: status >= 200 && status <= 599 && ![204, 205, 304].includes(status) ? status : 500 });
22
+ }
23
+ }
24
+ function refuse(status, message, options) {
25
+ try {
26
+ if (Sentry.isInitialized() && Sentry.isEnabled()) {
27
+ const extra = scrub(options.context);
28
+ const safeMessage = String(scrub(message));
29
+ if (options.kind === "expected") Sentry.addBreadcrumb({
30
+ category: "refuse",
31
+ level: "info",
32
+ message: safeMessage,
33
+ data: { ...extra, where: options.where, status }
34
+ });
35
+ else Sentry.captureMessage(safeMessage, {
36
+ level: "warning",
37
+ tags: { where: options.where },
38
+ extra,
39
+ fingerprint: ["refuse", options.where, template(message)]
40
+ });
41
+ }
42
+ } catch {
43
+ }
44
+ return response(message, status, options);
45
+ }
46
+ function fail(message, cause, options) {
47
+ try {
48
+ if (Sentry.isInitialized() && Sentry.isEnabled()) Sentry.captureException(cause, {
49
+ level: "error",
50
+ tags: { where: options.where },
51
+ extra: scrub(options.context)
52
+ });
53
+ } catch {
54
+ }
55
+ return response(message, options.status ?? 500, options);
56
+ }
57
+ export {
58
+ fail,
59
+ refuse
60
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@weatherboard/errors",
3
- "version": "0.1.0",
4
- "description": "Shared Next.js and browser error reporting for Weatherboard products.",
3
+ "version": "0.2.0",
4
+ "description": "Shared Next.js, browser, HTTP and Cloudflare Worker error reporting for Weatherboard products.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -10,7 +10,9 @@
10
10
  },
11
11
  "type": "module",
12
12
  "dependencies": {
13
- "@sentry/nextjs": "10.75.0"
13
+ "@sentry/cloudflare": "10.75.0",
14
+ "@sentry/nextjs": "10.75.0",
15
+ "typescript": "~5.7.2"
14
16
  },
15
17
  "peerDependencies": {
16
18
  "next": "^16.0.0",
@@ -41,6 +43,10 @@
41
43
  "types": "./client.d.ts",
42
44
  "import": "./client.js"
43
45
  },
46
+ "./http": {
47
+ "types": "./http.d.ts",
48
+ "import": "./http.js"
49
+ },
44
50
  "./noise": {
45
51
  "types": "./noise.d.ts",
46
52
  "import": "./noise.js"
@@ -48,6 +54,10 @@
48
54
  "./verify": {
49
55
  "types": "./verify.d.ts",
50
56
  "import": "./verify.js"
57
+ },
58
+ "./worker": {
59
+ "types": "./worker.d.ts",
60
+ "import": "./worker.js"
51
61
  }
52
62
  },
53
63
  "files": [
package/verify.d.ts CHANGED
@@ -2,3 +2,4 @@
2
2
  export declare function assertErrorWiring(appDirectory: string): void;
3
3
  /** Call from a product's node:test file with the absolute app directory. */
4
4
  export declare function verifyErrors(appDirectory: string): void;
5
+ export { assertWorkerWiring, verifyWorkerWiring } from "./worker-verify.js";
package/verify.js CHANGED
@@ -27,7 +27,10 @@ function assertErrorWiring(appDirectory) {
27
27
  function verifyErrors(appDirectory) {
28
28
  test("shared error reporting wiring", () => assertErrorWiring(appDirectory));
29
29
  }
30
+ import { assertWorkerWiring, verifyWorkerWiring } from "./worker-verify.js";
30
31
  export {
31
32
  assertErrorWiring,
32
- verifyErrors
33
+ assertWorkerWiring,
34
+ verifyErrors,
35
+ verifyWorkerWiring
33
36
  };
@@ -0,0 +1,4 @@
1
+ /** Inspect actual syntax so a commented-out wrapper cannot satisfy the guard. */
2
+ export declare function assertWorkerWiring(dir: string): void;
3
+ /** Call from each converted Worker's node:test suite. */
4
+ export declare function verifyWorkerWiring(dir: string): void;
@@ -0,0 +1,77 @@
1
+ import assert from "node:assert/strict";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+ import { test } from "node:test";
5
+ import ts from "typescript";
6
+ function sources(dir) {
7
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
8
+ if (["node_modules", ".git", "dist", "test-fixtures"].includes(entry.name)) return [];
9
+ const path = join(dir, entry.name);
10
+ if (entry.isDirectory()) return sources(path);
11
+ return /\.[cm]?[jt]s$/.test(entry.name) && !/\.(test|spec)\./.test(entry.name) && !entry.name.endsWith(".d.ts") ? [path] : [];
12
+ });
13
+ }
14
+ function assertWorkerWiring(dir) {
15
+ let defaults = 0;
16
+ const classes = [];
17
+ const wrapped = /* @__PURE__ */ new Set();
18
+ for (const file of sources(dir)) {
19
+ const name = relative(dir, file);
20
+ const source = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true);
21
+ const imports = /* @__PURE__ */ new Map();
22
+ const visit = (node) => {
23
+ if (ts.isStringLiteral(node) && node.text.startsWith("@sentry/")) {
24
+ const parent = node.parent;
25
+ if (ts.isImportDeclaration(parent) || ts.isExportDeclaration(parent) || ts.isCallExpression(parent)) {
26
+ assert.fail(`${name}: products never import @sentry/* directly`);
27
+ }
28
+ }
29
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === "@weatherboard/errors/worker") {
30
+ const bindings = node.importClause?.namedBindings;
31
+ if (bindings && ts.isNamedImports(bindings)) {
32
+ for (const binding of bindings.elements) imports.set(binding.name.text, binding.propertyName?.text ?? binding.name.text);
33
+ }
34
+ }
35
+ ts.forEachChild(node, visit);
36
+ };
37
+ visit(source);
38
+ const isWrapper = (expression, kind) => ts.isCallExpression(expression) && ts.isIdentifier(expression.expression) && imports.get(expression.expression.text) === kind;
39
+ for (const statement of source.statements) {
40
+ if (name.startsWith("src/") && ts.isExportAssignment(statement) && !statement.isExportEquals) {
41
+ defaults++;
42
+ assert.ok(isWrapper(statement.expression, "withWorkerErrors"), `${name}: default export must be wrapped with withWorkerErrors`);
43
+ }
44
+ if (ts.isClassDeclaration(statement) && statement.name) {
45
+ const base = statement.heritageClauses?.find((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword)?.types[0]?.expression;
46
+ if (base && ts.isIdentifier(base)) classes.push({ name: statement.name.text, base: base.text, file: name });
47
+ }
48
+ if (ts.isVariableStatement(statement) && statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
49
+ for (const declaration of statement.declarationList.declarations) {
50
+ if (declaration.initializer && isWrapper(declaration.initializer, "withDurableObjectErrors")) {
51
+ const arg = declaration.initializer.arguments[0];
52
+ if (arg && ts.isIdentifier(arg)) wrapped.add(`${name}:${arg.text}`);
53
+ }
54
+ }
55
+ }
56
+ }
57
+ }
58
+ assert.ok(defaults > 0, "Worker must have a wrapped default export");
59
+ const durable = /* @__PURE__ */ new Set(["DurableObject", "Sandbox"]);
60
+ for (let changed = true; changed; ) {
61
+ changed = false;
62
+ for (const item of classes) if (durable.has(item.base) && !durable.has(item.name)) {
63
+ durable.add(item.name);
64
+ changed = true;
65
+ }
66
+ }
67
+ for (const item of classes) if (durable.has(item.base)) {
68
+ assert.ok(wrapped.has(`${item.file}:${item.name}`), `${item.file}: Durable Object ${item.name} must be instrumented with withDurableObjectErrors`);
69
+ }
70
+ }
71
+ function verifyWorkerWiring(dir) {
72
+ test("shared Worker error reporting wiring", () => assertWorkerWiring(dir));
73
+ }
74
+ export {
75
+ assertWorkerWiring,
76
+ verifyWorkerWiring
77
+ };
package/worker.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { type CloudflareOptions } from "@sentry/cloudflare";
2
+ import type { HandledContext } from "./client.js";
3
+ /** Shared Workers must supply caller-derived environment explicitly; unset is development. */
4
+ export interface WorkerErrorEnv {
5
+ SENTRY_DSN?: string;
6
+ SENTRY_ENVIRONMENT?: string;
7
+ ENVIRONMENT?: string;
8
+ SENTRY_RELEASE?: string;
9
+ CF_VERSION_METADATA?: {
10
+ id: string;
11
+ tag?: string;
12
+ };
13
+ }
14
+ type Options<E> = (env: E) => CloudflareOptions;
15
+ export declare function workerErrorOptions(env: WorkerErrorEnv): CloudflareOptions;
16
+ /** Select instrumentation per invocation, without modifying the original handler. */
17
+ export declare function withWorkerErrors<H extends object>(handler: H, options?: Options<WorkerErrorEnv>): H;
18
+ /** Preserve the original construction path when a DSN is absent. */
19
+ export declare function withDurableObjectErrors<C extends new (...args: never[]) => object>(object: C, options?: Options<WorkerErrorEnv>): C;
20
+ /** Report inside an instrumented invocation's catch; logging stays at the call site. */
21
+ export declare function reportHandled(error: unknown, context: HandledContext): void;
22
+ export {};
package/worker.js ADDED
@@ -0,0 +1,70 @@
1
+ import {
2
+ captureException,
3
+ getClient,
4
+ instrumentDurableObjectWithSentry,
5
+ withSentry,
6
+ wrapRequestHandler
7
+ } from "@sentry/cloudflare";
8
+ function workerErrorOptions(env) {
9
+ return {
10
+ dsn: env.SENTRY_DSN,
11
+ environment: env.SENTRY_ENVIRONMENT || env.ENVIRONMENT || "development",
12
+ release: env.SENTRY_RELEASE || env.CF_VERSION_METADATA?.tag || env.CF_VERSION_METADATA?.id,
13
+ tracesSampleRate: 0,
14
+ sendDefaultPii: false,
15
+ // On SDK 10.75, enableRpcTracePropagation alone captures only calls carrying
16
+ // SDK metadata. Manager-side callers do not carry it; instrument all methods.
17
+ instrumentPrototypeMethods: true
18
+ };
19
+ }
20
+ function withWorkerErrors(handler, options = workerErrorOptions) {
21
+ let instrumented;
22
+ const methods = /* @__PURE__ */ new Map();
23
+ const wrapped = new Proxy(handler, {
24
+ get(target, key, receiver) {
25
+ const original = Reflect.get(target, key, receiver);
26
+ if (typeof original !== "function") return original;
27
+ if (!methods.has(key)) methods.set(key, new Proxy(original, {
28
+ apply(method, thisArg, args) {
29
+ const receiver2 = thisArg === wrapped ? handler : thisArg;
30
+ const env = args[1];
31
+ if (!env?.SENTRY_DSN) return Reflect.apply(method, receiver2, args);
32
+ if (key === "fetch" && args[0] instanceof Request && ["HEAD", "OPTIONS"].includes(args[0].method)) {
33
+ return wrapRequestHandler({ options: options(env), request: args[0], context: args[2] }, () => Reflect.apply(method, receiver2, args));
34
+ }
35
+ instrumented ??= withSentry(options, { ...handler });
36
+ const captured = Reflect.get(instrumented, key);
37
+ return Reflect.apply(typeof captured === "function" ? captured : method, receiver2, args);
38
+ }
39
+ }));
40
+ return methods.get(key);
41
+ }
42
+ });
43
+ return wrapped;
44
+ }
45
+ function withDurableObjectErrors(object, options = workerErrorOptions) {
46
+ let instrumented;
47
+ return new Proxy(object, {
48
+ construct(target, args, newTarget) {
49
+ const env = args[1];
50
+ if (!env?.SENTRY_DSN) return Reflect.construct(target, args, newTarget);
51
+ instrumented ??= instrumentDurableObjectWithSentry(options, object);
52
+ return Reflect.construct(instrumented, args, newTarget);
53
+ }
54
+ });
55
+ }
56
+ function reportHandled(error, context) {
57
+ try {
58
+ const client = getClient();
59
+ if (!client?.getOptions().dsn || client.getOptions().enabled === false) return;
60
+ const message = error instanceof Error ? error.message : String(error);
61
+ captureException(error, { fingerprint: [context.where, message], extra: context.extra });
62
+ } catch {
63
+ }
64
+ }
65
+ export {
66
+ reportHandled,
67
+ withDurableObjectErrors,
68
+ withWorkerErrors,
69
+ workerErrorOptions
70
+ };