@weatherboard/errors 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Weatherboard
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,60 @@
1
- # Temporary Holding Version
1
+ # @weatherboard/errors
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Products never import `@sentry/*` directly.
4
+
5
+ Entry points:
6
+ - `./next`: `withErrors(nextConfig, opts)` wraps Next config, exposes `$SENTRY_BUILD_ENVIRONMENT` and `$SENTRY_BUILD_RELEASE`, and uploads source maps only with all three upload variables. Maps are deleted after upload; budget builds can set `PERF_BUDGET_SOURCE_MAPS=keep`.
7
+ - `./next/server`: `register` and `onRequestError` for server instrumentation.
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
+ - `./next/global-error`: `GlobalError` reports render failures and renders its required `children`; the product supplies the fallback document and design.
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.
12
+ - `./noise`: shared patterns with their justifying issue ids.
13
+ - `./worker`: `withWorkerErrors`, `withDurableObjectErrors` and Worker `reportHandled`.
14
+ - `./verify`: `verifyErrors(appDirectory)` or `verifyWorkerWiring(workerDirectory)` registers the product wiring test.
15
+
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.
17
+
18
+ Wire a Next app in four files:
19
+
20
+ 1. In `next.config.mjs`: import `{ withErrors }` from `@weatherboard/errors/next` and export `withErrors(nextConfig)`.
21
+ 2. In `instrumentation.ts`: export `{ register, onRequestError }` from `@weatherboard/errors/next/server`.
22
+ 3. In `instrumentation-client.ts`: export `{ onRouterTransitionStart }` from `@weatherboard/errors/next/client`.
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>`.
24
+
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.
@@ -0,0 +1,15 @@
1
+ export type LoadWindow = {
2
+ readonly document: {
3
+ readonly readyState: string;
4
+ };
5
+ addEventListener(type: "load", listener: () => void, options: {
6
+ once: true;
7
+ }): void;
8
+ setTimeout(handler: () => void, timeout: number): unknown;
9
+ readonly requestIdleCallback?: (callback: () => void, options: {
10
+ timeout: number;
11
+ }) => unknown;
12
+ };
13
+ export declare const IDLE_TIMEOUT_MS = 2000;
14
+ export declare const IDLE_FALLBACK_MS = 200;
15
+ export declare function afterLoadAndIdle(win: LoadWindow, run: () => void): void;
package/after-load.js ADDED
@@ -0,0 +1,18 @@
1
+ const IDLE_TIMEOUT_MS = 2e3;
2
+ const IDLE_FALLBACK_MS = 200;
3
+ function afterLoadAndIdle(win, run) {
4
+ const whenIdle = () => {
5
+ if (typeof win.requestIdleCallback === "function") {
6
+ win.requestIdleCallback(run, { timeout: IDLE_TIMEOUT_MS });
7
+ } else {
8
+ win.setTimeout(run, IDLE_FALLBACK_MS);
9
+ }
10
+ };
11
+ if (win.document.readyState === "complete") whenIdle();
12
+ else win.addEventListener("load", whenIdle, { once: true });
13
+ }
14
+ export {
15
+ IDLE_FALLBACK_MS,
16
+ IDLE_TIMEOUT_MS,
17
+ afterLoadAndIdle
18
+ };
@@ -0,0 +1,3 @@
1
+ import * as Sentry from "@sentry/nextjs";
2
+ export declare const captureException: typeof Sentry.captureException;
3
+ export declare const captureRouterTransitionStart: typeof Sentry.captureRouterTransitionStart;
package/browser-sdk.js ADDED
@@ -0,0 +1,27 @@
1
+ import * as Sentry from "@sentry/nextjs";
2
+ import { attachReporter } from "./client.js";
3
+ import { SENTRY_IGNORE_ERRORS } from "./noise.js";
4
+ Sentry.init({
5
+ dsn: process.env.PUBLIC_SENTRY_DSN,
6
+ environment: $SENTRY_BUILD_ENVIRONMENT,
7
+ release: $SENTRY_BUILD_RELEASE || void 0,
8
+ tracesSampleRate: 0,
9
+ sendDefaultPii: false,
10
+ defaultIntegrations: false,
11
+ integrations: [
12
+ Sentry.globalHandlersIntegration(),
13
+ Sentry.dedupeIntegration(),
14
+ Sentry.breadcrumbsIntegration(),
15
+ Sentry.linkedErrorsIntegration(),
16
+ Sentry.functionToStringIntegration(),
17
+ Sentry.browserSessionIntegration()
18
+ ],
19
+ ignoreErrors: SENTRY_IGNORE_ERRORS
20
+ });
21
+ attachReporter(Sentry);
22
+ const captureException = Sentry.captureException;
23
+ const captureRouterTransitionStart = Sentry.captureRouterTransitionStart;
24
+ export {
25
+ captureException,
26
+ captureRouterTransitionStart
27
+ };
package/client.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { type CaptureContext } from "./sentry-deferred.js";
2
+ export type HandledContext = {
3
+ where: string;
4
+ extra?: Record<string, unknown>;
5
+ };
6
+ type Reporter = {
7
+ isEnabled(): boolean;
8
+ captureException(error: unknown, context: CaptureContext): unknown;
9
+ };
10
+ /** Internal SDK registration shared by server and browser entry points. */
11
+ export declare function attachReporter(reporter: Reporter): void;
12
+ /** UI catch sites must never fail while reporting the original error. No SDK import on this path. */
13
+ export declare function reportHandled(error: unknown, context: HandledContext): void;
14
+ export {};
package/client.js ADDED
@@ -0,0 +1,21 @@
1
+ import { captureException } from "./sentry-deferred.js";
2
+ const KEY = /* @__PURE__ */ Symbol.for("weatherboard.errors.reporter");
3
+ const holder = globalThis;
4
+ function attachReporter(reporter) {
5
+ holder[KEY] = reporter;
6
+ }
7
+ function reportHandled(error, context) {
8
+ try {
9
+ const sdk = holder[KEY];
10
+ if (sdk && !sdk.isEnabled()) return;
11
+ const message = error instanceof Error ? error.message : String(error);
12
+ const captureContext = { fingerprint: [context.where, message], tags: { where: context.where }, extra: context.extra };
13
+ if (sdk) sdk.captureException(error, captureContext);
14
+ else captureException(error, captureContext);
15
+ } catch {
16
+ }
17
+ }
18
+ export {
19
+ attachReporter,
20
+ reportHandled
21
+ };
@@ -0,0 +1,2 @@
1
+ /** Only deployed Vercel environments report errors; NODE_ENV is deliberately irrelevant. */
2
+ export declare function deployedEnvironment(value: string | undefined): "production" | "preview" | null;
package/environment.js ADDED
@@ -0,0 +1,6 @@
1
+ function deployedEnvironment(value) {
2
+ return value === "production" || value === "preview" ? value : null;
3
+ }
4
+ export {
5
+ deployedEnvironment
6
+ };
@@ -0,0 +1,9 @@
1
+ import { type ReactNode } from "react";
2
+ /** Reporting only. The product owns its document and fallback design. */
3
+ export declare function GlobalError({ error, children }: {
4
+ error: Error & {
5
+ digest?: string;
6
+ };
7
+ children: ReactNode;
8
+ }): ReactNode;
9
+ export default GlobalError;
@@ -0,0 +1,14 @@
1
+ "use client";
2
+ import { useEffect } from "react";
3
+ import { captureException } from "./sentry-deferred.js";
4
+ function GlobalError({ error, children }) {
5
+ useEffect(() => {
6
+ captureException(error);
7
+ }, [error]);
8
+ return children;
9
+ }
10
+ var global_error_default = GlobalError;
11
+ export {
12
+ GlobalError,
13
+ global_error_default as default
14
+ };
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
+ };
@@ -0,0 +1 @@
1
+ export { onRouterTransitionStart } from "./sentry-deferred.js";
@@ -0,0 +1,13 @@
1
+ import { afterLoadAndIdle } from "./after-load.js";
2
+ import { bufferEarlyErrors, registerSentryLoader, loadSentryNow } from "./sentry-deferred.js";
3
+ import { onRouterTransitionStart } from "./sentry-deferred.js";
4
+ if (typeof window !== "undefined" && process.env.PUBLIC_SENTRY_DSN && typeof $SENTRY_BUILD_ENVIRONMENT !== "undefined" && ($SENTRY_BUILD_ENVIRONMENT === "production" || $SENTRY_BUILD_ENVIRONMENT === "preview")) {
5
+ registerSentryLoader(() => import("./browser-sdk.js"));
6
+ bufferEarlyErrors(window);
7
+ afterLoadAndIdle(window, () => {
8
+ void loadSentryNow();
9
+ });
10
+ }
11
+ export {
12
+ onRouterTransitionStart
13
+ };
package/next.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /** @param {import('next').NextConfig} nextConfig
2
+ * @param {{ silent?: boolean }} [opts]
3
+ * @returns {import('next').NextConfig} */
4
+ export function withErrors(nextConfig: import("next").NextConfig, opts?: {
5
+ silent?: boolean;
6
+ }): import("next").NextConfig;
package/next.js ADDED
@@ -0,0 +1,47 @@
1
+ import { withSentryConfig } from "@sentry/nextjs/config";
2
+ function withErrors(nextConfig, opts = {}) {
3
+ const required = ["SENTRY_ORG", "SENTRY_PROJECT", "SENTRY_AUTH_TOKEN"];
4
+ const missing = required.filter((name) => !process.env[name]);
5
+ const upload = missing.length === 0;
6
+ if (!upload) console.info(`[errors] source maps will NOT be uploaded: ${missing.join(", ")} not set.`);
7
+ const constants = {
8
+ $SENTRY_BUILD_ENVIRONMENT: process.env.VERCEL_ENV ?? "development",
9
+ $SENTRY_BUILD_RELEASE: process.env.VERCEL_GIT_COMMIT_SHA ?? "",
10
+ __SENTRY_DEBUG__: false,
11
+ __SENTRY_TRACING__: false,
12
+ __RRWEB_EXCLUDE_SHADOW_DOM__: true,
13
+ __RRWEB_EXCLUDE_IFRAME__: true,
14
+ __SENTRY_EXCLUDE_REPLAY_WORKER__: true
15
+ };
16
+ return withSentryConfig({
17
+ ...nextConfig,
18
+ env: { ...nextConfig.env, PUBLIC_SENTRY_DSN: process.env.PUBLIC_SENTRY_DSN ?? "" },
19
+ compiler: { ...nextConfig.compiler, define: { ...nextConfig.compiler?.define, ...constants } },
20
+ productionBrowserSourceMaps: true,
21
+ webpack(config, context) {
22
+ const configured = nextConfig.webpack ? nextConfig.webpack(config, context) : config;
23
+ (configured.plugins ??= []).push(new context.webpack.DefinePlugin(Object.fromEntries(
24
+ Object.entries(constants).map(([key, value]) => [key, JSON.stringify(value)])
25
+ )));
26
+ return configured;
27
+ }
28
+ }, {
29
+ org: upload ? process.env.SENTRY_ORG : void 0,
30
+ project: upload ? process.env.SENTRY_PROJECT : void 0,
31
+ authToken: upload ? process.env.SENTRY_AUTH_TOKEN : void 0,
32
+ sourcemaps: { disable: !upload, deleteSourcemapsAfterUpload: process.env.PERF_BUDGET_SOURCE_MAPS !== "keep" },
33
+ telemetry: false,
34
+ silent: opts.silent ?? false,
35
+ bundleSizeOptimizations: {
36
+ excludeDebugStatements: true,
37
+ excludeTracing: true,
38
+ excludeReplayShadowDom: true,
39
+ excludeReplayIframe: true,
40
+ excludeReplayWorker: true
41
+ },
42
+ webpack: { treeshake: { removeDebugLogging: true }, automaticVercelMonitors: false }
43
+ });
44
+ }
45
+ export {
46
+ withErrors
47
+ };
package/noise.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ export declare const NOISE_ENTRIES: readonly [{
2
+ readonly issue: "A-806";
3
+ readonly pattern: RegExp;
4
+ }, {
5
+ readonly issue: "A-806";
6
+ readonly pattern: RegExp;
7
+ }, {
8
+ readonly issue: "A-806";
9
+ readonly pattern: RegExp;
10
+ }, {
11
+ readonly issue: "A-806";
12
+ readonly pattern: RegExp;
13
+ }, {
14
+ readonly issue: "A-806";
15
+ readonly pattern: RegExp;
16
+ }, {
17
+ readonly issue: "A-806";
18
+ readonly pattern: RegExp;
19
+ }, {
20
+ readonly issue: "A-806";
21
+ readonly pattern: RegExp;
22
+ }, {
23
+ readonly issue: "A-806";
24
+ readonly pattern: "AbortError: The operation was aborted.";
25
+ }, {
26
+ readonly issue: "A-806";
27
+ readonly pattern: "AbortError: Fetch is aborted";
28
+ }, {
29
+ readonly issue: "A-806";
30
+ readonly pattern: "AbortError: signal is aborted without reason";
31
+ }, {
32
+ readonly issue: "A-806";
33
+ readonly pattern: "TypeError: Load failed";
34
+ }, {
35
+ readonly issue: "A-806";
36
+ readonly pattern: "TypeError: Failed to fetch";
37
+ }, {
38
+ readonly issue: "A-806";
39
+ readonly pattern: "TypeError: NetworkError when attempting to fetch resource.";
40
+ }, {
41
+ readonly issue: "A-806";
42
+ readonly pattern: "TypeError: cancelled";
43
+ }, {
44
+ readonly issue: "A-806";
45
+ readonly pattern: "ResizeObserver loop limit exceeded";
46
+ }, {
47
+ readonly issue: "A-806";
48
+ readonly pattern: "ResizeObserver loop completed with undelivered notifications";
49
+ }, {
50
+ readonly issue: "A-806";
51
+ readonly pattern: RegExp;
52
+ }, {
53
+ readonly issue: "A-806";
54
+ readonly pattern: RegExp;
55
+ }];
56
+ export declare const SENTRY_IGNORE_ERRORS: (string | RegExp)[];
57
+ export declare function isIgnoredMessage(message: string, patterns?: (string | RegExp)[]): boolean;
package/noise.js ADDED
@@ -0,0 +1,29 @@
1
+ const NOISE_ENTRIES = [
2
+ { issue: "A-806", pattern: /window\.__firefox__/ },
3
+ { issue: "A-806", pattern: /Can't find variable: __firefox__/ },
4
+ { issue: "A-806", pattern: /Invalid call to runtime\.sendMessage\(\)\. Tab not found/i },
5
+ { issue: "A-806", pattern: /Extension context invalidated/i },
6
+ { issue: "A-806", pattern: /chrome-extension:\/\// },
7
+ { issue: "A-806", pattern: /moz-extension:\/\// },
8
+ { issue: "A-806", pattern: /window\.ethereum/i },
9
+ { issue: "A-806", pattern: "AbortError: The operation was aborted." },
10
+ { issue: "A-806", pattern: "AbortError: Fetch is aborted" },
11
+ { issue: "A-806", pattern: "AbortError: signal is aborted without reason" },
12
+ { issue: "A-806", pattern: "TypeError: Load failed" },
13
+ { issue: "A-806", pattern: "TypeError: Failed to fetch" },
14
+ { issue: "A-806", pattern: "TypeError: NetworkError when attempting to fetch resource." },
15
+ { issue: "A-806", pattern: "TypeError: cancelled" },
16
+ { issue: "A-806", pattern: "ResizeObserver loop limit exceeded" },
17
+ { issue: "A-806", pattern: "ResizeObserver loop completed with undelivered notifications" },
18
+ { issue: "A-806", pattern: /Object Not Found Matching Id:\d+, MethodName:update/ },
19
+ { issue: "A-806", pattern: /Non-Error promise rejection captured/ }
20
+ ];
21
+ const SENTRY_IGNORE_ERRORS = NOISE_ENTRIES.map((entry) => entry.pattern);
22
+ function isIgnoredMessage(message, patterns = SENTRY_IGNORE_ERRORS) {
23
+ return patterns.some((pattern) => typeof pattern === "string" ? message.includes(pattern) : pattern.test(message));
24
+ }
25
+ export {
26
+ NOISE_ENTRIES,
27
+ SENTRY_IGNORE_ERRORS,
28
+ isIgnoredMessage
29
+ };
package/package.json CHANGED
@@ -1,6 +1,69 @@
1
1
  {
2
2
  "name": "@weatherboard/errors",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Shared Next.js, browser, HTTP and Cloudflare Worker error reporting for Weatherboard products.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Weatherboard-Studio/weatherboard-studio.git",
9
+ "directory": "packages/errors"
10
+ },
11
+ "type": "module",
12
+ "dependencies": {
13
+ "@sentry/cloudflare": "10.75.0",
14
+ "@sentry/nextjs": "10.75.0",
15
+ "typescript": "~5.7.2"
16
+ },
17
+ "peerDependencies": {
18
+ "next": "^16.0.0",
19
+ "react": "^19.0.0"
20
+ },
21
+ "sideEffects": [
22
+ "./instrumentation-client.js",
23
+ "./browser-sdk.js"
24
+ ],
25
+ "exports": {
26
+ "./next": {
27
+ "types": "./next.d.ts",
28
+ "import": "./next.js"
29
+ },
30
+ "./next/server": {
31
+ "types": "./server.d.ts",
32
+ "import": "./server.js"
33
+ },
34
+ "./next/client": {
35
+ "types": "./instrumentation-client.d.ts",
36
+ "import": "./instrumentation-client.js"
37
+ },
38
+ "./next/global-error": {
39
+ "types": "./global-error.d.ts",
40
+ "import": "./global-error.js"
41
+ },
42
+ "./client": {
43
+ "types": "./client.d.ts",
44
+ "import": "./client.js"
45
+ },
46
+ "./http": {
47
+ "types": "./http.d.ts",
48
+ "import": "./http.js"
49
+ },
50
+ "./noise": {
51
+ "types": "./noise.d.ts",
52
+ "import": "./noise.js"
53
+ },
54
+ "./verify": {
55
+ "types": "./verify.d.ts",
56
+ "import": "./verify.js"
57
+ },
58
+ "./worker": {
59
+ "types": "./worker.d.ts",
60
+ "import": "./worker.js"
61
+ }
62
+ },
63
+ "files": [
64
+ "*.js",
65
+ "*.d.ts",
66
+ "README.md",
67
+ "LICENSE"
68
+ ]
6
69
  }
@@ -0,0 +1,32 @@
1
+ export type CaptureContext = {
2
+ mechanism: {
3
+ type: string;
4
+ handled: boolean;
5
+ };
6
+ } | {
7
+ fingerprint: string[];
8
+ tags: Record<string, string>;
9
+ extra?: Record<string, unknown>;
10
+ };
11
+ export type DeferredSentrySdk = {
12
+ captureException(error: unknown, hint?: CaptureContext): unknown;
13
+ captureRouterTransitionStart(href: string, navigationType: string): void;
14
+ };
15
+ export type ErrorTarget = {
16
+ addEventListener(type: "error" | "unhandledrejection", listener: (event: unknown) => void): void;
17
+ removeEventListener(type: "error" | "unhandledrejection", listener: (event: unknown) => void): void;
18
+ };
19
+ export declare const ONERROR_MECHANISM: {
20
+ readonly type: "auto.browser.global_handlers.onerror";
21
+ readonly handled: false;
22
+ };
23
+ export declare const ONREJECTION_MECHANISM: {
24
+ readonly type: "auto.browser.global_handlers.onunhandledrejection";
25
+ readonly handled: false;
26
+ };
27
+ export declare function bufferEarlyErrors(target: ErrorTarget): void;
28
+ export declare function registerSentryLoader(load: () => Promise<DeferredSentrySdk>): void;
29
+ export declare function loadSentryNow(): Promise<DeferredSentrySdk | null>;
30
+ export declare function captureException(error: unknown, context?: CaptureContext): void;
31
+ export declare function onRouterTransitionStart(href: string, navigationType: string): void;
32
+ export declare function resetSentryDeferredForTests(): void;
@@ -0,0 +1,101 @@
1
+ const ONERROR_MECHANISM = { type: "auto.browser.global_handlers.onerror", handled: false };
2
+ const ONREJECTION_MECHANISM = { type: "auto.browser.global_handlers.onunhandledrejection", handled: false };
3
+ const KEY = /* @__PURE__ */ Symbol.for("weatherboard.sentry-deferred");
4
+ function state() {
5
+ const holder = globalThis;
6
+ let current = holder[KEY];
7
+ if (current === void 0) {
8
+ current = { sdk: null, queue: [], load: null, loading: null, detach: null };
9
+ holder[KEY] = current;
10
+ }
11
+ return current;
12
+ }
13
+ function errorOf(event) {
14
+ const value = event;
15
+ if (value !== null && typeof value === "object") {
16
+ if (value.error !== void 0 && value.error !== null) return value.error;
17
+ if (typeof value.message === "string") return value.message;
18
+ }
19
+ return event;
20
+ }
21
+ function reasonOf(event) {
22
+ const value = event;
23
+ return value !== null && typeof value === "object" && "reason" in value ? value.reason : event;
24
+ }
25
+ function bufferEarlyErrors(target) {
26
+ const current = state();
27
+ if (current.detach !== null || current.sdk !== null) return;
28
+ const onError = (event) => record({ error: errorOf(event), context: { mechanism: ONERROR_MECHANISM } });
29
+ const onRejection = (event) => record({ error: reasonOf(event), context: { mechanism: ONREJECTION_MECHANISM } });
30
+ target.addEventListener("error", onError);
31
+ target.addEventListener("unhandledrejection", onRejection);
32
+ current.detach = () => {
33
+ target.removeEventListener("error", onError);
34
+ target.removeEventListener("unhandledrejection", onRejection);
35
+ };
36
+ }
37
+ function record(pending) {
38
+ const current = state();
39
+ if (current.sdk !== null) {
40
+ current.sdk.captureException(pending.error, pending.context);
41
+ return;
42
+ }
43
+ current.queue.push(pending);
44
+ void loadSentryNow();
45
+ }
46
+ function registerSentryLoader(load) {
47
+ state().load = load;
48
+ }
49
+ function loadSentryNow() {
50
+ const current = state();
51
+ if (current.loading !== null) return current.loading;
52
+ if (current.load === null) return Promise.resolve(null);
53
+ current.loading = current.load().then(
54
+ (sdk) => {
55
+ current.sdk = sdk;
56
+ current.detach?.();
57
+ current.detach = null;
58
+ const queued = current.queue.splice(0);
59
+ for (const pending of queued) {
60
+ sdk.captureException(pending.error, pending.context);
61
+ }
62
+ return sdk;
63
+ },
64
+ (error) => {
65
+ console.info("[sentry] the error-reporting SDK failed to load; errors on this page are not being reported.", error);
66
+ return null;
67
+ }
68
+ );
69
+ return current.loading;
70
+ }
71
+ function captureException(error, context) {
72
+ const current = state();
73
+ if (current.load === null && current.sdk === null) return;
74
+ if (current.sdk !== null) {
75
+ current.sdk.captureException(error, context);
76
+ return;
77
+ }
78
+ record({ error, context });
79
+ }
80
+ function onRouterTransitionStart(href, navigationType) {
81
+ state().sdk?.captureRouterTransitionStart(href, navigationType);
82
+ }
83
+ function resetSentryDeferredForTests() {
84
+ const current = state();
85
+ current.detach?.();
86
+ current.sdk = null;
87
+ current.queue = [];
88
+ current.load = null;
89
+ current.loading = null;
90
+ current.detach = null;
91
+ }
92
+ export {
93
+ ONERROR_MECHANISM,
94
+ ONREJECTION_MECHANISM,
95
+ bufferEarlyErrors,
96
+ captureException,
97
+ loadSentryNow,
98
+ onRouterTransitionStart,
99
+ registerSentryLoader,
100
+ resetSentryDeferredForTests
101
+ };
@@ -0,0 +1,4 @@
1
+ import * as SentryModule from "@sentry/nextjs";
2
+ declare const Sentry: typeof SentryModule;
3
+ export declare function initialiseServer(overrides?: Pick<Parameters<typeof Sentry.init>[0] & {}, "transport">): void;
4
+ export {};
package/server-sdk.js ADDED
@@ -0,0 +1,23 @@
1
+ import * as SentryModule from "@sentry/nextjs";
2
+ const Sentry = SentryModule.default ?? SentryModule;
3
+ import { attachReporter } from "./client.js";
4
+ import { deployedEnvironment } from "./environment.js";
5
+ import { SENTRY_IGNORE_ERRORS } from "./noise.js";
6
+ function initialiseServer(overrides = {}) {
7
+ const environment = deployedEnvironment(process.env.VERCEL_ENV);
8
+ const dsn = process.env.SENTRY_DSN;
9
+ if (!dsn || !environment) return;
10
+ Sentry.init({
11
+ dsn,
12
+ environment,
13
+ release: process.env.VERCEL_GIT_COMMIT_SHA || void 0,
14
+ tracesSampleRate: 0,
15
+ sendDefaultPii: false,
16
+ ignoreErrors: SENTRY_IGNORE_ERRORS,
17
+ ...overrides
18
+ });
19
+ attachReporter(Sentry);
20
+ }
21
+ export {
22
+ initialiseServer
23
+ };
package/server.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { captureRequestError as onRequestError } from "@sentry/nextjs";
2
+ export declare function register(options?: Pick<import("@sentry/nextjs").NodeOptions, "transport">): Promise<void>;
package/server.js ADDED
@@ -0,0 +1,12 @@
1
+ import { deployedEnvironment } from "./environment.js";
2
+ import { captureRequestError } from "@sentry/nextjs";
3
+ async function register(options = {}) {
4
+ if (process.env.NEXT_RUNTIME !== "nodejs") return;
5
+ if (!process.env.SENTRY_DSN || !deployedEnvironment(process.env.VERCEL_ENV)) return;
6
+ const { initialiseServer } = await import("./server-sdk.js");
7
+ initialiseServer(options);
8
+ }
9
+ export {
10
+ captureRequestError as onRequestError,
11
+ register
12
+ };
package/verify.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ /** Assert wiring directly; exported separately so a fixture can prove failure. */
2
+ export declare function assertErrorWiring(appDirectory: string): void;
3
+ /** Call from a product's node:test file with the absolute app directory. */
4
+ export declare function verifyErrors(appDirectory: string): void;
5
+ export { assertWorkerWiring, verifyWorkerWiring } from "./worker-verify.js";
package/verify.js ADDED
@@ -0,0 +1,36 @@
1
+ import assert from "node:assert/strict";
2
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+ import { test } from "node:test";
5
+ function sources(dir) {
6
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
7
+ if (["node_modules", ".next", ".git", "dist"].includes(entry.name)) return [];
8
+ const path = join(dir, entry.name);
9
+ if (entry.isDirectory()) return sources(path);
10
+ return /\.[cm]?[jt]sx?$/.test(entry.name) && !/\.(test|spec)\./.test(entry.name) && !entry.name.endsWith(".d.ts") ? [path] : [];
11
+ });
12
+ }
13
+ function assertErrorWiring(appDirectory) {
14
+ for (const entry of ["instrumentation.ts", "instrumentation-client.ts"]) {
15
+ const source = readFileSync(join(appDirectory, entry), "utf8");
16
+ assert.match(source, /(?:from\s*|import\s*)["']@weatherboard\/errors\//, `${entry} must import @weatherboard/errors`);
17
+ }
18
+ assert.ok(["src/app/global-error.tsx", "app/global-error.tsx", "src/app/global-error.jsx", "app/global-error.jsx"].some((path) => existsSync(join(appDirectory, path))), "a global-error boundary must exist");
19
+ for (const file of sources(appDirectory)) {
20
+ const source = readFileSync(file, "utf8");
21
+ const name = relative(appDirectory, file);
22
+ assert.doesNotMatch(source, /(?:from\s*|import\s*(?:\(\s*)?|require\s*\(\s*|export\s*[^;]*from\s*)["']@sentry\//, `${name}: products never import @sentry/* directly`);
23
+ assert.doesNotMatch(source, /\bposthog\s*(?:\.\s*captureException|\[\s*["']captureException["']\s*\])/, `${name}: PostHog is analytics only`);
24
+ assert.doesNotMatch(source, /["']?capture_exceptions["']?\s*:\s*true\b/, `${name}: PostHog exception autocapture must be off`);
25
+ }
26
+ }
27
+ function verifyErrors(appDirectory) {
28
+ test("shared error reporting wiring", () => assertErrorWiring(appDirectory));
29
+ }
30
+ import { assertWorkerWiring, verifyWorkerWiring } from "./worker-verify.js";
31
+ export {
32
+ assertErrorWiring,
33
+ assertWorkerWiring,
34
+ verifyErrors,
35
+ verifyWorkerWiring
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
+ };