diff --git a/CHANGELOG.md b/CHANGELOG.md index 5727ec77..bd7d7e4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ Contributors: add user-facing changes under **[Unreleased]** in your PR to `deve ### Added +- **`@telemetry-tracker/core` 1.5.0** — publish `ingestError()` so fatal Node handlers can await ingest before exit (Refs [#711](https://github.com/Telemetry-Tracker/telemetry-tracker/issues/711)) +- **`@telemetry-tracker/node` 1.4.0** — depends on core `^1.5.0`; flush-then-exit for `uncaughtException` / `unhandledRejection` (opt out of rejection exit via `exitOnUnhandledRejection: false`); middleware times response finish and calls `next()` once (Refs [#711](https://github.com/Telemetry-Tracker/telemetry-tracker/issues/711), [#719](https://github.com/Telemetry-Tracker/telemetry-tracker/issues/719), [#720](https://github.com/Telemetry-Tracker/telemetry-tracker/issues/720), [#632](https://github.com/Telemetry-Tracker/telemetry-tracker/issues/632)) +- **`@telemetry-tracker/vite-plugin` 1.1.0** — publish `sourceMappingURL`-based `bundle_url` resolution (Refs [#718](https://github.com/Telemetry-Tracker/telemetry-tracker/issues/718)) + ### Security - **Post-login redirects (TT-017)** — `next` on `/login` (and legacy `signIn=1` flows) is validated against the app origin so protocol-relative, backslash, control-character, and absolute external values fall back to `/dashboard/overview`. Legitimate paths with query strings (e.g. `/dashboard/errors?range=7d`) are preserved. @@ -24,6 +28,8 @@ Contributors: add user-facing changes under **[Unreleased]** in your PR to `deve ### Changed +- **SDK publish** — `pnpm publish:packages` requires a clean tagged `origin/main` checkout with per-package version tags pushed, stamps `gitHead`, blocks direct folder publishes / `workspace:*`, aborts if core fails before dependents, and runs publish-guard tests in CI + ### Database --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb2413f5..23f32512 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,7 +97,26 @@ SDKs are published as `@telemetry-tracker/*` on npm: | `packages/telemetry-react-native` | `@telemetry-tracker/react-native` | | `packages/telemetry-vite-plugin` | `@telemetry-tracker/vite-plugin` | -Publish (maintainers): create the `@telemetry-tracker` npm org, `npm login`, then `pnpm publish:packages`. After the first publish under the new scope, deprecate the legacy `@tacko/telemetry-*` packages with a message pointing to `@telemetry-tracker/*`. +Publish (maintainers): from a **clean checkout of `origin/main`** with per-package release tags pushed (so `gitHead` on npm matches the commit): + +```bash +git fetch origin main --tags +git checkout main && git pull origin main +# tags on this commit, e.g. sdk-core-v1.5.0 sdk-node-v1.4.0 sdk-vite-plugin-v1.1.0 +pnpm publish:packages -- --only=core,node,vite-plugin --otp=123456 +# local dry run (only this combo may skip clean/tag checks): +pnpm publish:dry -- --only=core,node,vite-plugin --allow-dirty +``` + +The publish script: + +- refuses a dirty tree, an untagged HEAD, HEAD ≠ `origin/main`, or tags not pushed to origin +- requires a tag matching each package version (`sdk--v` or `@`) +- stamps `gitHead`, rewrites `workspace:*` → `^` for the tarball only +- sets `TELEMETRY_SDK_RELEASE_PUBLISH=1` (package `prepublishOnly` blocks direct folder publishes) +- **aborts** if core publish fails so node is not published against a missing core + +`--allow-dirty` alone is rejected for real publishes. After the first publish under the new scope, deprecate the legacy `@tacko/telemetry-*` packages with a message pointing to `@telemetry-tracker/*`. Design and entitlement rules are summarized in [docs/ENTITLEMENTS.md](docs/ENTITLEMENTS.md); architecture in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md); deployment in [DEPLOYMENT.md](DEPLOYMENT.md) and [docs/RAILWAY.md](docs/RAILWAY.md); RBAC in [docs/RBAC.md](docs/RBAC.md). diff --git a/README.md b/README.md index f70921af..1aee4fb9 100644 --- a/README.md +++ b/README.md @@ -272,7 +272,7 @@ Please follow the [Code of Conduct](CODE_OF_CONDUCT.md). Report security issues | SDK guides | [docs/sdk-core.md](docs/sdk-core.md), [docs/sdk-next.md](docs/sdk-next.md), [docs/sdk-node.md](docs/sdk-node.md), [docs/sdk-nestjs.md](docs/sdk-nestjs.md), [docs/sdk-vue.md](docs/sdk-vue.md), [docs/sdk-nuxt.md](docs/sdk-nuxt.md), [docs/sdk-react-native.md](docs/sdk-react-native.md) | | Source maps | [docs/source-maps.md](docs/source-maps.md) | -**Publish SDK packages:** `npm login` → `pnpm publish:packages` (see [CONTRIBUTING.md](CONTRIBUTING.md) and root `package.json` scripts). +**Publish SDK packages:** from a clean tagged checkout, `npm login` → `pnpm publish:packages` (optional `--only=core,node,vite-plugin`). See [CONTRIBUTING.md](CONTRIBUTING.md) and root `package.json` scripts. **GitHub social preview:** In repo **Settings → General → Social preview**, use `https://telemetry-tracker.com/og-banner.png` (1024×409 marketing banner) once the dashboard is deployed. Install path for docs and marketing: `@telemetry-tracker/core` (see npm badges above). diff --git a/apps/dashboard/app/docs/node/page.tsx b/apps/dashboard/app/docs/node/page.tsx index aec7e9c5..6463920f 100644 --- a/apps/dashboard/app/docs/node/page.tsx +++ b/apps/dashboard/app/docs/node/page.tsx @@ -52,15 +52,18 @@ trackError(new Error("DB connection failed"), { db: "primary" });`}

Global error handlers

After init(), uncaughtException and{" "} - unhandledRejection are patched to send errors to the ingest API (and then - rethrow / continue so your process can still exit or log as usual). + unhandledRejection are patched to send errors to the ingest API, flush (up to + 2s), then process.exit(1) — matching Node’s default crash behaviour. Set{" "} + exitOnUnhandledRejection: false if you only want rejections reported without + exiting.

Request middleware

Optional: use middleware() to send a $request event per HTTP - request (method, url, duration). Attach it to your server framework (Express, Fastify, - NestJS, etc.) so it runs for each request. + request (method, url, duration_ms until the response finishes). Attach it to + your server framework (Express, Fastify, NestJS, etc.) so it runs for each request.{" "} + next() is called exactly once.

@telemetry-tracker/node wraps core for servers. After init(), it installs handlers for uncaughtException and{" "} - unhandledRejection, then rethrows / continues so your process can still exit - or log as usual. Optional request middleware sends a $request event per HTTP - call. + unhandledRejection, flushes the error to ingest, then exits with code 1 + (Node’s default). Set exitOnUnhandledRejection: false to only report + rejections and keep the process running. Optional request middleware sends a{" "} + $request event per HTTP call with duration_ms until the + response finishes.

} > diff --git a/docs/sdk-node.md b/docs/sdk-node.md index bc8a030d..6e93f859 100644 --- a/docs/sdk-node.md +++ b/docs/sdk-node.md @@ -10,12 +10,14 @@ In a monorepo workspace: pnpm add @telemetry-tracker/node ``` +Requires `@telemetry-tracker/core` **^1.5.0** (provides `ingestError` for fatal flushes). + ## Setup Call **`init(config)`** once at process startup (e.g. before starting your HTTP server). This will: - Initialize the core SDK. -- Register `process.on("uncaughtException")` and `process.on("unhandledRejection")` to report those errors before rethrowing (or exiting). +- Register `process.on("uncaughtException")` and `process.on("unhandledRejection")` to report those errors, flush ingest (up to 2s), then exit. ```ts import { init, trackEvent, trackError } from "@telemetry-tracker/node"; @@ -25,6 +27,7 @@ init({ app: "my-backend", apiKey: process.env.TELEMETRY_API_KEY, platform: "node", // default + // exitOnUnhandledRejection: true, // default — report, flush, exit(1) }); ``` @@ -41,14 +44,21 @@ init({ Config extends [telemetry-core](sdk-core.md#initconfig) and requires `app`; `platform` defaults to `"node"`. +| Option | Default | Description | +|--------|---------|-------------| +| `exitOnUnhandledRejection` | `true` | After reporting an unhandled rejection, flush and `process.exit(1)` (Node’s default since v15). Set `false` to only report and keep running. | +| `fatalFlushTimeoutMs` | `2000` | Max wait for fatal ingest before exit. Cleared when ingest settles (does not keep the process alive). | + ## Global error handlers After `init()`: -- **uncaughtException**: Error is reported with `{ source: "uncaughtException" }`, then rethrown (process typically exits). -- **unhandledRejection**: Reason is reported as an error with `{ source: "unhandledRejection" }`. +- **uncaughtException**: Error is reported with `{ source: "uncaughtException" }`, ingest is flushed (≤ `fatalFlushTimeoutMs`, default 2s), then the process exits with code 1. Non-Error throws (`null`, strings, objects, …) are normalized first. +- **unhandledRejection**: Reason is reported with `{ source: "unhandledRejection" }`. By default the process then flushes and exits with code 1 (same as Node without the SDK). Set `exitOnUnhandledRejection: false` to keep the legacy “report only” behaviour. + +With `node --unhandled-rejections=strict`, rejections are also raised as uncaught exceptions; the SDK still reports once and exits 1 (in-flight ingest is awaited if you already called `trackError(err)` before rethrowing). -You can still use `trackError` in try/catch or domain handlers for extra context. +You can still use `trackError` in try/catch for extra context. ## Request middleware @@ -58,26 +68,21 @@ You can still use `trackError` in try/catch or domain handlers for extra context (req, res, next) => void ``` -It records a `$request` event with: +`duration_ms` is measured from middleware entry until the **response** emits `finish` or `close` (not the request body `end`). `next()` is called exactly once. -- `method`, `url`, `duration_ms` -- Optionally `body` when `opts.trackRequestBody === true` +Options: -The implementation assumes a minimal `req`: `method`, `url`, and optionally `body` and `on(event, listener)`. It is not tied to Express or Fastify; you can adapt it or use it in a custom stack. Example (conceptual): +- `trackRequestBody` (default `false`): when true, includes `req.body` in the `$request` event properties (use carefully — may contain PII). ```ts import { init, middleware } from "@telemetry-tracker/node"; -init({ ingestUrl: "http://localhost:3001", app: "api", apiKey: process.env.TELEMETRY_API_KEY, environment: "development" }); +init({ ingestUrl: "...", app: "api" }); const telemetryMiddleware = middleware({ trackRequestBody: false }); -// Use in your stack; call next() so the request continues. -function handleRequest(req, res) { - telemetryMiddleware(req, res, () => { - // your handler - }); -} +// Express +app.use(telemetryMiddleware); ``` For Express you’d typically do `app.use(telemetryMiddleware)` if the middleware calls `next()` and matches Express’ (req, res, next) shape. Our middleware is generic and may need a thin wrapper to match your framework’s expectations. For **NestJS**, see [sdk-nestjs.md](sdk-nestjs.md). diff --git a/package.json b/package.json index 18eeb79d..95a11ea6 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,14 @@ "dev:dashboard": "pnpm --filter dashboard dev", "build": "pnpm -r run build", "lint": "eslint apps packages --max-warnings 0", - "test": "pnpm --filter api test && pnpm --filter dashboard test && pnpm --filter @telemetry-tracker/core test && pnpm --filter @telemetry-tracker/vite-plugin test && pnpm --filter @telemetry-tracker/node test && pnpm --filter @telemetry-tracker/next test", + "test": "pnpm --filter api test && pnpm --filter dashboard test && pnpm --filter @telemetry-tracker/core test && pnpm --filter @telemetry-tracker/vite-plugin test && pnpm --filter @telemetry-tracker/node test && pnpm --filter @telemetry-tracker/next test && pnpm test:publish-guards", "db:generate": "pnpm --filter api exec prisma generate", "db:migrate": "pnpm --filter api exec prisma migrate dev", "db:studio": "pnpm --filter api exec prisma studio", "db:seed-api-key": "pnpm --filter api seed:dev-api-key", "publish:packages": "pnpm run build && node scripts/publish-packages.mjs", - "publish:dry": "pnpm run build && node scripts/publish-packages.mjs --dry-run" + "publish:dry": "pnpm run build && node scripts/publish-packages.mjs --dry-run", + "test:publish-guards": "node --test scripts/lib/publish-guards.test.mjs" }, "devDependencies": { "@eslint/eslintrc": "^3.3.5", diff --git a/packages/telemetry-core/dist/index.d.ts b/packages/telemetry-core/dist/index.d.ts index 43f3ba60..7e35c763 100644 --- a/packages/telemetry-core/dist/index.d.ts +++ b/packages/telemetry-core/dist/index.d.ts @@ -1,5 +1,6 @@ import { SDK_VERSION } from "./version.js"; export { SDK_VERSION }; +export { toReportableError } from "./to-reportable-error.js"; export { scrubPiiText, scrubPiiRecord } from "./pii-scrub.js"; export { WEB_VITAL_EVENT_NAME, installWebVitals, rateWebVital, buildWebVitalProperties, setWebVitalsCaptureEnabled, isWebVitalsCaptureEnabled, type WebVitalEventProperties, type WebVitalMetricName, type WebVitalRating, } from "./web-vitals.js"; export declare function getAnonymousId(): string; @@ -49,15 +50,9 @@ declare function resolveClientPiiScrub(cfg: TelemetryConfig | null): { /** @internal exported for tests */ export { resolveClientPiiScrub }; export declare function trackEvent(name: string, properties?: Record): void; -export declare function trackError(error: Error | { - message: string; - stack?: string; -}, context?: Record): void; +export declare function trackError(error: unknown, context?: Record): void; /** Send an error and resolve after the ingest request settles. Fatal handlers await this. */ -export declare function ingestError(error: Error | { - message: string; - stack?: string; -}, context?: Record): Promise; +export declare function ingestError(error: unknown, context?: Record): Promise; export declare function screen(name: string): void; export declare function getUserId(): string | null; export declare function getConfigOrNull(): TelemetryConfig | null; diff --git a/packages/telemetry-core/dist/index.d.ts.map b/packages/telemetry-core/dist/index.d.ts.map index 343c946e..a1e66d4d 100644 --- a/packages/telemetry-core/dist/index.d.ts.map +++ b/packages/telemetry-core/dist/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAE3C,OAAO,EAAE,WAAW,EAAE,CAAC;AACvB,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC9D,OAAO,EACL,oBAAoB,EACpB,gBAAgB,EAChB,YAAY,EACZ,uBAAuB,EACvB,0BAA0B,EAC1B,yBAAyB,EACzB,KAAK,uBAAuB,EAC5B,KAAK,kBAAkB,EACvB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AAsCzB,wBAAgB,cAAc,IAAI,MAAM,CAkBvC;AAED,MAAM,MAAM,uBAAuB,GAC/B,OAAO,GACP;IACE,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;CACrB,CAAC;AAEN,MAAM,MAAM,eAAe,GAAG;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,sFAAsF;IACtF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qEAAqE;IACrE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,+CAA+C;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8EAA8E;IAC9E,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,uBAAuB,CAAC;CACpC,CAAC;AA0HF,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAE5C;AAED,kEAAkE;AAClE,wBAAgB,UAAU,IAAI,IAAI,CAOjC;AA4CD,wBAAgB,IAAI,CAAC,CAAC,EAAE,eAAe,GAAG,IAAI,CAwB7C;AAED;;;GAGG;AACH,wBAAgB,QAAQ,IAAI,IAAI,CAS/B;AAQD,MAAM,MAAM,cAAc,GAAG;IAC3B,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,CAAC;AAEF,wBAAgB,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,EAAE,MAAM,CAAC,EAAE,cAAc,GAAG,IAAI,CASzE;AAYD,uEAAuE;AACvE,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,QAAQ,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAO/F;AA6ED,iBAAS,qBAAqB,CAC5B,GAAG,EAAE,eAAe,GAAG,IAAI,GAC1B;IAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,IAAI,CAahC;AAED,mCAAmC;AACnC,OAAO,EAAE,qBAAqB,EAAE,CAAC;AAWjC,wBAAgB,UAAU,CACxB,IAAI,EAAE,MAAM,EACZ,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,IAAI,CAsBN;AAED,wBAAgB,UAAU,CACxB,KAAK,EAAE,KAAK,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,EAClD,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,IAAI,CAEN;AAED,6FAA6F;AAC7F,wBAAgB,WAAW,CACzB,KAAK,EAAE,KAAK,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,EAClD,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,OAAO,CAAC,IAAI,CAAC,CA0Bf;AAED,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAEzC;AAED,wBAAgB,SAAS,IAAI,MAAM,GAAG,IAAI,CAEzC;AAED,wBAAgB,eAAe,IAAI,eAAe,GAAG,IAAI,CAExD"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAG3C,OAAO,EAAE,WAAW,EAAE,CAAC;AACvB,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC9D,OAAO,EACL,oBAAoB,EACpB,gBAAgB,EAChB,YAAY,EACZ,uBAAuB,EACvB,0BAA0B,EAC1B,yBAAyB,EACzB,KAAK,uBAAuB,EAC5B,KAAK,kBAAkB,EACvB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AAyCzB,wBAAgB,cAAc,IAAI,MAAM,CAkBvC;AAED,MAAM,MAAM,uBAAuB,GAC/B,OAAO,GACP;IACE,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;CACrB,CAAC;AAEN,MAAM,MAAM,eAAe,GAAG;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,sFAAsF;IACtF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qEAAqE;IACrE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,+CAA+C;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8EAA8E;IAC9E,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,uBAAuB,CAAC;CACpC,CAAC;AA0HF,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAE5C;AAED,kEAAkE;AAClE,wBAAgB,UAAU,IAAI,IAAI,CAOjC;AA4CD,wBAAgB,IAAI,CAAC,CAAC,EAAE,eAAe,GAAG,IAAI,CAwB7C;AAED;;;GAGG;AACH,wBAAgB,QAAQ,IAAI,IAAI,CAS/B;AAQD,MAAM,MAAM,cAAc,GAAG;IAC3B,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,CAAC;AAEF,wBAAgB,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,EAAE,MAAM,CAAC,EAAE,cAAc,GAAG,IAAI,CASzE;AAYD,uEAAuE;AACvE,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,QAAQ,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAO/F;AA6ED,iBAAS,qBAAqB,CAC5B,GAAG,EAAE,eAAe,GAAG,IAAI,GAC1B;IAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,IAAI,CAahC;AAED,mCAAmC;AACnC,OAAO,EAAE,qBAAqB,EAAE,CAAC;AAWjC,wBAAgB,UAAU,CACxB,IAAI,EAAE,MAAM,EACZ,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,IAAI,CAsBN;AAED,wBAAgB,UAAU,CACxB,KAAK,EAAE,OAAO,EACd,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,IAAI,CAEN;AAED,6FAA6F;AAC7F,wBAAgB,WAAW,CACzB,KAAK,EAAE,OAAO,EACd,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,OAAO,CAAC,IAAI,CAAC,CA+Cf;AAED,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAEzC;AAED,wBAAgB,SAAS,IAAI,MAAM,GAAG,IAAI,CAEzC;AAED,wBAAgB,eAAe,IAAI,eAAe,GAAG,IAAI,CAExD"} \ No newline at end of file diff --git a/packages/telemetry-core/dist/index.js b/packages/telemetry-core/dist/index.js index f22e12b8..02f15ad8 100644 --- a/packages/telemetry-core/dist/index.js +++ b/packages/telemetry-core/dist/index.js @@ -2,11 +2,16 @@ import { readDeviceContext } from "./device-context.js"; import { installWebVitals, setWebVitalsCaptureEnabled, WEB_VITAL_EVENT_NAME, } from "./web-vitals.js"; import { scrubPiiRecord, scrubPiiText } from "./pii-scrub.js"; import { SDK_VERSION } from "./version.js"; +import { toReportableError } from "./to-reportable-error.js"; export { SDK_VERSION }; +export { toReportableError } from "./to-reportable-error.js"; export { scrubPiiText, scrubPiiRecord } from "./pii-scrub.js"; export { WEB_VITAL_EVENT_NAME, installWebVitals, rateWebVital, buildWebVitalProperties, setWebVitalsCaptureEnabled, isWebVitalsCaptureEnabled, } from "./web-vitals.js"; -const REPORTED = Symbol.for("telemetry.reported"); const ANON_STORAGE_KEY = "tacko_telemetry_anon_id"; +/** In-flight ingest promises so a later fatal flush can await trackError(e); throw e. */ +const inFlightIngest = new WeakMap(); +/** Completed error reports — WeakSet so we never mutate frozen/sealed Error objects. */ +const reportedErrors = new WeakSet(); let anonymousId = null; let fallbackIdSeq = 0; function bytesToUuid(bytes) { @@ -411,34 +416,49 @@ export function trackError(error, context) { } /** Send an error and resolve after the ingest request settles. Fatal handlers await this. */ export function ingestError(error, context) { - const cfg = getConfigOrNull(); - if (!cfg) - return Promise.resolve(); - const err = error instanceof Error ? error : { message: error.message, stack: error.stack }; - if (err && typeof err === "object" && err[REPORTED]) { - return Promise.resolve(); - } - let message = err instanceof Error ? err.message : err.message; - let stack = err instanceof Error ? err.stack : err.stack; - let scrubbedContext = context ?? undefined; - const scrubOpts = resolveClientPiiScrub(cfg); - if (scrubOpts) { - message = scrubPiiText(message); - if (stack != null) - stack = scrubPiiText(stack); - if (scrubbedContext != null) { - scrubbedContext = scrubPiiRecord(scrubbedContext, scrubOpts); + try { + const cfg = getConfigOrNull(); + if (!cfg) + return Promise.resolve(); + const err = toReportableError(error); + const existing = inFlightIngest.get(err); + if (existing) + return existing; + if (reportedErrors.has(err)) { + return Promise.resolve(); } + let message = err.message; + let stack = err.stack; + let scrubbedContext = context ?? undefined; + const scrubOpts = resolveClientPiiScrub(cfg); + if (scrubOpts) { + message = scrubPiiText(message); + if (stack != null) + stack = scrubPiiText(stack); + if (scrubbedContext != null) { + scrubbedContext = scrubPiiRecord(scrubbedContext, scrubOpts); + } + } + // Non-mutating dedupe — safe for Object.freeze / seal / preventExtensions. + reportedErrors.add(err); + const pending = send("/ingest/error", { + message, + stack: stack ?? undefined, + context: scrubbedContext, + user_id: userId ?? undefined, + session_id: sessionId ?? undefined, + }) + .catch(() => { }) + .finally(() => { + inFlightIngest.delete(err); + }); + inFlightIngest.set(err, pending); + return pending; + } + catch { + // Never let telemetry-internal failures escape into the caller's crash path. + return Promise.resolve(); } - if (err instanceof Error) - err[REPORTED] = true; - return send("/ingest/error", { - message, - stack: stack ?? undefined, - context: scrubbedContext, - user_id: userId ?? undefined, - session_id: sessionId ?? undefined, - }).catch(() => { }); } export function screen(name) { trackEvent("$screen", { name }); diff --git a/packages/telemetry-core/dist/to-reportable-error.d.ts b/packages/telemetry-core/dist/to-reportable-error.d.ts new file mode 100644 index 00000000..6bec909d --- /dev/null +++ b/packages/telemetry-core/dist/to-reportable-error.d.ts @@ -0,0 +1,3 @@ +/** Normalize any thrown/rejected value into a reportable Error. */ +export declare function toReportableError(value: unknown): Error; +//# sourceMappingURL=to-reportable-error.d.ts.map \ No newline at end of file diff --git a/packages/telemetry-core/dist/to-reportable-error.d.ts.map b/packages/telemetry-core/dist/to-reportable-error.d.ts.map new file mode 100644 index 00000000..8a43d391 --- /dev/null +++ b/packages/telemetry-core/dist/to-reportable-error.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"to-reportable-error.d.ts","sourceRoot":"","sources":["../src/to-reportable-error.ts"],"names":[],"mappings":"AAAA,mEAAmE;AACnE,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,CAuBvD"} \ No newline at end of file diff --git a/packages/telemetry-core/dist/to-reportable-error.js b/packages/telemetry-core/dist/to-reportable-error.js new file mode 100644 index 00000000..cc050712 --- /dev/null +++ b/packages/telemetry-core/dist/to-reportable-error.js @@ -0,0 +1,30 @@ +/** Normalize any thrown/rejected value into a reportable Error. */ +export function toReportableError(value) { + if (value instanceof Error) + return value; + if (value === null) + return new Error("null"); + if (value === undefined) + return new Error("undefined"); + if (typeof value === "string") + return new Error(value); + if (typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint") { + return new Error(String(value)); + } + if (typeof value === "symbol") + return new Error(value.toString()); + if (typeof value === "object") { + try { + const json = JSON.stringify(value); + if (typeof json === "string") + return new Error(json); + } + catch { + // circular / non-serializable + } + return new Error(Object.prototype.toString.call(value)); + } + return new Error(String(value)); +} diff --git a/packages/telemetry-core/dist/version.d.ts b/packages/telemetry-core/dist/version.d.ts index cd8bc24f..7b808bf3 100644 --- a/packages/telemetry-core/dist/version.d.ts +++ b/packages/telemetry-core/dist/version.d.ts @@ -1,3 +1,3 @@ /** Injected at build time from package.json. Do not edit manually. */ -export declare const SDK_VERSION = "1.4.0"; +export declare const SDK_VERSION = "1.5.0"; //# sourceMappingURL=version.d.ts.map \ No newline at end of file diff --git a/packages/telemetry-core/dist/version.js b/packages/telemetry-core/dist/version.js index 352a1fea..73cde4c0 100644 --- a/packages/telemetry-core/dist/version.js +++ b/packages/telemetry-core/dist/version.js @@ -1,2 +1,2 @@ /** Injected at build time from package.json. Do not edit manually. */ -export const SDK_VERSION = "1.4.0"; +export const SDK_VERSION = "1.5.0"; diff --git a/packages/telemetry-core/package.json b/packages/telemetry-core/package.json index 97a4ddea..30be960d 100644 --- a/packages/telemetry-core/package.json +++ b/packages/telemetry-core/package.json @@ -1,6 +1,6 @@ { "name": "@telemetry-tracker/core", - "version": "1.4.0", + "version": "1.5.0", "description": "Lightweight telemetry client: events, errors, sessions. Framework-agnostic core.", "license": "MIT", "repository": { @@ -24,7 +24,7 @@ "build": "node scripts/inject-version.cjs && rm -rf dist && tsc", "dev": "tsc --watch", "test": "vitest run", - "prepublishOnly": "pnpm run build" + "prepublishOnly": "node ../../scripts/assert-sdk-publish-context.mjs && pnpm run build" }, "keywords": [ "telemetry", diff --git a/packages/telemetry-core/src/index.test.ts b/packages/telemetry-core/src/index.test.ts index 73c80d77..6a851ea9 100644 --- a/packages/telemetry-core/src/index.test.ts +++ b/packages/telemetry-core/src/index.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { buildIngestHeaders, init, + ingestError, shutdown, trackEvent, trackError, @@ -27,7 +28,8 @@ describe("ingest fetch", () => { beforeEach(() => { vi.stubGlobal("fetch", fetchMock); - fetchMock.mockClear(); + fetchMock.mockReset(); + fetchMock.mockResolvedValue({ ok: true, text: async () => "" }); init({ ingestUrl: "http://localhost:3001", app: "test-app", @@ -63,4 +65,101 @@ describe("ingest fetch", () => { trackError(new Error("after_shutdown")); expect(fetchMock).not.toHaveBeenCalled(); }); + + it("ingestError returns a Promise that settles after POST /ingest/error (core 1.5)", async () => { + const pending = ingestError(new Error("fatal"), { source: "uncaughtException" }); + expect(pending).toBeInstanceOf(Promise); + await pending; + const errorCall = fetchMock.mock.calls.find((c) => + String(c[0]).includes("/ingest/error") + ); + expect(errorCall).toBeTruthy(); + const [, opts] = errorCall as [string, RequestInit]; + expect(JSON.parse(String(opts.body))).toMatchObject({ + message: "fatal", + context: { source: "uncaughtException" }, + }); + }); + + it.each([ + [null, "null"], + [undefined, "undefined"], + ["str", "str"], + [9, "9"], + [{ x: 1 }, '{"x":1}'], + ])("ingestError normalizes %j", async (value, message) => { + await ingestError(value, { source: "uncaughtException" }); + const errorCall = fetchMock.mock.calls.find((c) => + String(c[0]).includes("/ingest/error") + ); + expect(errorCall).toBeTruthy(); + const body = JSON.parse(String((errorCall![1] as RequestInit).body)); + expect(body.message).toBe(message); + }); + + it("awaits an in-flight trackError send on a second ingestError (trackError; throw)", async () => { + fetchMock.mockClear(); + let release!: () => void; + fetchMock.mockImplementation( + () => + new Promise((resolve) => { + release = () => resolve({ ok: true, text: async () => "" }); + }) + ); + const err = new Error("shared"); + trackError(err, { source: "manual" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const second = ingestError(err, { source: "uncaughtException" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + let secondDone = false; + void second.then(() => { + secondDone = true; + }); + await new Promise((r) => setTimeout(r, 20)); + expect(secondDone).toBe(false); + release(); + await second; + expect(secondDone).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it.each([ + ["frozen", (e: Error) => Object.freeze(e)], + ["sealed", (e: Error) => Object.seal(e)], + ["non-extensible", (e: Error) => Object.preventExtensions(e)], + ] as const)("ingestError reports %s Error objects without mutating them", async (_label, lock) => { + fetchMock.mockClear(); + const err = lock(new Error("locked")); + await expect(ingestError(err, { source: "uncaughtException" })).resolves.toBeUndefined(); + const errorCall = fetchMock.mock.calls.find((c) => + String(c[0]).includes("/ingest/error") + ); + expect(errorCall).toBeTruthy(); + expect(JSON.parse(String((errorCall![1] as RequestInit).body)).message).toBe("locked"); + // Second call is deduped without a new fetch. + await ingestError(err, { source: "uncaughtException" }); + const errorCalls = fetchMock.mock.calls.filter((c) => + String(c[0]).includes("/ingest/error") + ); + expect(errorCalls).toHaveLength(1); + }); + + it("awaits in-flight trackError for a frozen Error (trackError; throw)", async () => { + fetchMock.mockClear(); + let release!: () => void; + fetchMock.mockImplementation( + () => + new Promise((resolve) => { + release = () => resolve({ ok: true, text: async () => "" }); + }) + ); + const err = Object.freeze(new Error("frozen-shared")); + trackError(err, { source: "manual" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const second = ingestError(err, { source: "uncaughtException" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + release(); + await second; + expect(fetchMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/telemetry-core/src/index.ts b/packages/telemetry-core/src/index.ts index 477950d1..e2dbcd38 100644 --- a/packages/telemetry-core/src/index.ts +++ b/packages/telemetry-core/src/index.ts @@ -8,8 +8,10 @@ import { import { scrubPiiRecord, scrubPiiText } from "./pii-scrub.js"; import { SDK_VERSION } from "./version.js"; +import { toReportableError } from "./to-reportable-error.js"; export { SDK_VERSION }; +export { toReportableError } from "./to-reportable-error.js"; export { scrubPiiText, scrubPiiRecord } from "./pii-scrub.js"; export { WEB_VITAL_EVENT_NAME, @@ -23,10 +25,13 @@ export { type WebVitalRating, } from "./web-vitals.js"; -const REPORTED = Symbol.for("telemetry.reported"); - const ANON_STORAGE_KEY = "tacko_telemetry_anon_id"; +/** In-flight ingest promises so a later fatal flush can await trackError(e); throw e. */ +const inFlightIngest = new WeakMap>(); +/** Completed error reports — WeakSet so we never mutate frozen/sealed Error objects. */ +const reportedErrors = new WeakSet(); + let anonymousId: string | null = null; let fallbackIdSeq = 0; @@ -498,7 +503,7 @@ export function trackEvent( } export function trackError( - error: Error | { message: string; stack?: string }, + error: unknown, context?: Record ): void { void ingestError(error, context); @@ -506,34 +511,55 @@ export function trackError( /** Send an error and resolve after the ingest request settles. Fatal handlers await this. */ export function ingestError( - error: Error | { message: string; stack?: string }, + error: unknown, context?: Record ): Promise { - const cfg = getConfigOrNull(); - if (!cfg) return Promise.resolve(); - const err = error instanceof Error ? error : { message: error.message, stack: error.stack }; - if (err && typeof err === "object" && (err as unknown as Record)[REPORTED]) { - return Promise.resolve(); - } - let message = err instanceof Error ? err.message : err.message; - let stack = err instanceof Error ? err.stack : err.stack; - let scrubbedContext = context ?? undefined; - const scrubOpts = resolveClientPiiScrub(cfg); - if (scrubOpts) { - message = scrubPiiText(message); - if (stack != null) stack = scrubPiiText(stack); - if (scrubbedContext != null) { - scrubbedContext = scrubPiiRecord(scrubbedContext, scrubOpts); + try { + const cfg = getConfigOrNull(); + if (!cfg) return Promise.resolve(); + + const err = toReportableError(error); + + const existing = inFlightIngest.get(err); + if (existing) return existing; + + if (reportedErrors.has(err)) { + return Promise.resolve(); + } + + let message = err.message; + let stack = err.stack; + let scrubbedContext = context ?? undefined; + const scrubOpts = resolveClientPiiScrub(cfg); + if (scrubOpts) { + message = scrubPiiText(message); + if (stack != null) stack = scrubPiiText(stack); + if (scrubbedContext != null) { + scrubbedContext = scrubPiiRecord(scrubbedContext, scrubOpts); + } } + + // Non-mutating dedupe — safe for Object.freeze / seal / preventExtensions. + reportedErrors.add(err); + + const pending = send("/ingest/error", { + message, + stack: stack ?? undefined, + context: scrubbedContext, + user_id: userId ?? undefined, + session_id: sessionId ?? undefined, + }) + .catch(() => {}) + .finally(() => { + inFlightIngest.delete(err); + }); + + inFlightIngest.set(err, pending); + return pending; + } catch { + // Never let telemetry-internal failures escape into the caller's crash path. + return Promise.resolve(); } - if (err instanceof Error) (err as unknown as Record)[REPORTED] = true; - return send("/ingest/error", { - message, - stack: stack ?? undefined, - context: scrubbedContext, - user_id: userId ?? undefined, - session_id: sessionId ?? undefined, - }).catch(() => {}); } export function screen(name: string): void { diff --git a/packages/telemetry-core/src/to-reportable-error.test.ts b/packages/telemetry-core/src/to-reportable-error.test.ts new file mode 100644 index 00000000..3e9aa2a4 --- /dev/null +++ b/packages/telemetry-core/src/to-reportable-error.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { toReportableError } from "./to-reportable-error.js"; + +describe("toReportableError", () => { + it("returns Error instances unchanged", () => { + const err = new Error("keep"); + expect(toReportableError(err)).toBe(err); + }); + + it("normalizes null and undefined", () => { + expect(toReportableError(null).message).toBe("null"); + expect(toReportableError(undefined).message).toBe("undefined"); + }); + + it("normalizes primitives", () => { + expect(toReportableError("boom").message).toBe("boom"); + expect(toReportableError(42).message).toBe("42"); + expect(toReportableError(true).message).toBe("true"); + }); + + it("normalizes plain objects", () => { + expect(toReportableError({ a: 1 }).message).toBe('{"a":1}'); + }); +}); diff --git a/packages/telemetry-core/src/to-reportable-error.ts b/packages/telemetry-core/src/to-reportable-error.ts new file mode 100644 index 00000000..3890f9fb --- /dev/null +++ b/packages/telemetry-core/src/to-reportable-error.ts @@ -0,0 +1,25 @@ +/** Normalize any thrown/rejected value into a reportable Error. */ +export function toReportableError(value: unknown): Error { + if (value instanceof Error) return value; + if (value === null) return new Error("null"); + if (value === undefined) return new Error("undefined"); + if (typeof value === "string") return new Error(value); + if ( + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ) { + return new Error(String(value)); + } + if (typeof value === "symbol") return new Error(value.toString()); + if (typeof value === "object") { + try { + const json = JSON.stringify(value); + if (typeof json === "string") return new Error(json); + } catch { + // circular / non-serializable + } + return new Error(Object.prototype.toString.call(value)); + } + return new Error(String(value)); +} diff --git a/packages/telemetry-core/src/version.ts b/packages/telemetry-core/src/version.ts index 352a1fea..73cde4c0 100644 --- a/packages/telemetry-core/src/version.ts +++ b/packages/telemetry-core/src/version.ts @@ -1,2 +1,2 @@ /** Injected at build time from package.json. Do not edit manually. */ -export const SDK_VERSION = "1.4.0"; +export const SDK_VERSION = "1.5.0"; diff --git a/packages/telemetry-next/package.json b/packages/telemetry-next/package.json index 0bd5885b..8de73126 100644 --- a/packages/telemetry-next/package.json +++ b/packages/telemetry-next/package.json @@ -28,7 +28,7 @@ "build": "tsc", "dev": "tsc --watch", "test": "vitest run", - "prepublishOnly": "pnpm run build" + "prepublishOnly": "node ../../scripts/assert-sdk-publish-context.mjs && pnpm run build" }, "keywords": [ "telemetry", diff --git a/packages/telemetry-node/dist/fatal.d.ts b/packages/telemetry-node/dist/fatal.d.ts index 8c559cdc..40401bd5 100644 --- a/packages/telemetry-node/dist/fatal.d.ts +++ b/packages/telemetry-node/dist/fatal.d.ts @@ -1,7 +1,17 @@ /** Bound how long a crashing process waits for the error ingest request. */ export declare const FATAL_FLUSH_TIMEOUT_MS = 2000; -export declare function flushFatalError(err: Error, source: string, deps: { +export type FatalFlushDeps = { ingest: (error: Error, context: Record) => Promise; exit: (code: number) => void; + /** Override the default {@link FATAL_FLUSH_TIMEOUT_MS}. */ timeoutMs?: number; -}): void; +}; +/** + * Report a fatal error, wait up to `timeoutMs` for ingest, then exit(1). + * Accepts any thrown value (null/undefined/primitives/objects). + * Clears the timeout when ingest settles so the timer does not keep the process alive. + * Telemetry-internal failures must not escape (would become Node exit code 7). + */ +export declare function flushFatalError(thrown: unknown, source: string, deps: FatalFlushDeps): void; +/** @internal test helper */ +export declare function resetFatalFlushStateForTests(): void; diff --git a/packages/telemetry-node/dist/fatal.js b/packages/telemetry-node/dist/fatal.js index 778371ef..ccef7a9a 100644 --- a/packages/telemetry-node/dist/fatal.js +++ b/packages/telemetry-node/dist/fatal.js @@ -1,12 +1,74 @@ +import { toReportableError } from "@telemetry-tracker/core"; /** Bound how long a crashing process waits for the error ingest request. */ export const FATAL_FLUSH_TIMEOUT_MS = 2000; -export function flushFatalError(err, source, deps) { - const timeoutMs = deps.timeoutMs ?? FATAL_FLUSH_TIMEOUT_MS; - const flush = deps.ingest(err, { source }).catch(() => undefined); - const timer = new Promise((resolve) => { - setTimeout(resolve, timeoutMs); - }); - void Promise.race([flush, timer]).finally(() => { +/** Prevents double exit when strict mode raises rejection as uncaughtException too. */ +let fatalFlushInProgress = false; +function safeExit(deps) { + try { deps.exit(1); - }); + } + catch { + // ignore — never throw from the crash handler + } +} +/** + * Report a fatal error, wait up to `timeoutMs` for ingest, then exit(1). + * Accepts any thrown value (null/undefined/primitives/objects). + * Clears the timeout when ingest settles so the timer does not keep the process alive. + * Telemetry-internal failures must not escape (would become Node exit code 7). + */ +export function flushFatalError(thrown, source, deps) { + try { + if (fatalFlushInProgress) + return; + fatalFlushInProgress = true; + const timeoutMs = deps.timeoutMs ?? FATAL_FLUSH_TIMEOUT_MS; + let settled = false; + let timeoutId; + let flush; + try { + const err = toReportableError(thrown); + let pending; + try { + pending = Promise.resolve(deps.ingest(err, { source })); + } + catch { + pending = Promise.resolve(); + } + flush = pending.catch(() => undefined); + } + catch { + flush = Promise.resolve(); + } + const timer = new Promise((resolve) => { + timeoutId = setTimeout(resolve, timeoutMs); + const handle = timeoutId; + if (typeof handle.unref === "function") + handle.unref(); + }); + const finish = () => { + if (settled) + return; + settled = true; + fatalFlushInProgress = false; + if (timeoutId !== undefined) { + try { + clearTimeout(timeoutId); + } + catch { + // ignore + } + } + safeExit(deps); + }; + void Promise.race([flush, timer]).then(finish, finish); + } + catch { + fatalFlushInProgress = false; + safeExit(deps); + } +} +/** @internal test helper */ +export function resetFatalFlushStateForTests() { + fatalFlushInProgress = false; } diff --git a/packages/telemetry-node/dist/index.d.ts b/packages/telemetry-node/dist/index.d.ts index 36930830..9321381b 100644 --- a/packages/telemetry-node/dist/index.d.ts +++ b/packages/telemetry-node/dist/index.d.ts @@ -2,15 +2,50 @@ import { identify, trackEvent, trackError as coreTrackError, type TelemetryConfi export type TelemetryNodeConfig = TelemetryConfig & { app: string; platform?: string; + /** + * When true (default), report an unhandled rejection, flush ingest, then + * `process.exit(1)` — matching Node’s default crash since v15. Set `false` + * to only report and keep the process running (legacy 1.3.x behaviour). + * + * With `node --unhandled-rejections=strict`, rejections are also raised as + * uncaught exceptions; the SDK de-dupes a single fatal flush per error. + */ + exitOnUnhandledRejection?: boolean; + /** + * Max ms to wait for fatal error ingest before exiting. Default 2000. + * The timeout is cleared when ingest settles and does not keep the process alive. + */ + fatalFlushTimeoutMs?: number; }; export { FATAL_FLUSH_TIMEOUT_MS, flushFatalError } from "./fatal.js"; +/** @internal Exported for unit tests. */ +export declare function createUncaughtExceptionHandler(deps: { + ingest: (error: Error, context: Record) => Promise; + exit: (code: number) => void; + timeoutMs?: number; +}): (err: unknown) => void; +/** @internal Exported for unit tests. */ +export declare function createUnhandledRejectionHandler(deps: { + exitOnUnhandledRejection: boolean; + ingest: (error: Error, context: Record) => Promise; + trackError: (error: Error, context: Record) => void; + exit: (code: number) => void; + timeoutMs?: number; +}): (reason: unknown) => void; export declare function init(config: TelemetryNodeConfig): void; export { identify, trackEvent, coreTrackError as trackError }; export declare function getConfig(): TelemetryConfig | null; +type MaybeEmitter = { + on?(event: string, listener: () => void): void; +}; +/** + * Generic (req, res, next) middleware. Times duration until the **response** + * finishes (`finish`/`close`), not the request body stream. + */ export declare function middleware(opts?: { trackRequestBody?: boolean; }): (req: { method?: string; url?: string; body?: unknown; -}, _res: unknown, next: () => void) => void; +}, res: MaybeEmitter | unknown, next: () => void) => void; diff --git a/packages/telemetry-node/dist/index.js b/packages/telemetry-node/dist/index.js index 1e2b28fb..65431361 100644 --- a/packages/telemetry-node/dist/index.js +++ b/packages/telemetry-node/dist/index.js @@ -1,23 +1,61 @@ -import { init as coreInit, identify, trackEvent, trackError as coreTrackError, ingestError, getConfigOrNull, } from "@telemetry-tracker/core"; -import { flushFatalError } from "./fatal.js"; +import { init as coreInit, identify, trackEvent, trackError as coreTrackError, ingestError, getConfigOrNull, toReportableError, } from "@telemetry-tracker/core"; +import { FATAL_FLUSH_TIMEOUT_MS, flushFatalError } from "./fatal.js"; export { FATAL_FLUSH_TIMEOUT_MS, flushFatalError } from "./fatal.js"; let installed = false; +let exitOnUnhandledRejection = true; +let fatalFlushTimeoutMs = FATAL_FLUSH_TIMEOUT_MS; +/** @internal Exported for unit tests. */ +export function createUncaughtExceptionHandler(deps) { + return (err) => { + flushFatalError(err, "uncaughtException", deps); + }; +} +/** @internal Exported for unit tests. */ +export function createUnhandledRejectionHandler(deps) { + return (reason) => { + const err = toReportableError(reason); + if (deps.exitOnUnhandledRejection) { + flushFatalError(err, "unhandledRejection", { + ingest: deps.ingest, + exit: deps.exit, + timeoutMs: deps.timeoutMs, + }); + return; + } + deps.trackError(err, { source: "unhandledRejection" }); + }; +} function installGlobalHandlers() { if (installed) return; installed = true; - process.on("uncaughtException", (err) => { - flushFatalError(err, "uncaughtException", { - ingest: ingestError, - exit: (code) => process.exit(code), - }); - }); - process.on("unhandledRejection", (reason) => { - const err = reason instanceof Error ? reason : new Error(String(reason)); - coreTrackError(err, { source: "unhandledRejection" }); - }); + process.on("uncaughtException", createUncaughtExceptionHandler({ + ingest: ingestError, + exit: (code) => process.exit(code), + get timeoutMs() { + return fatalFlushTimeoutMs; + }, + })); + process.on("unhandledRejection", createUnhandledRejectionHandler({ + get exitOnUnhandledRejection() { + return exitOnUnhandledRejection; + }, + ingest: ingestError, + trackError: coreTrackError, + exit: (code) => process.exit(code), + get timeoutMs() { + return fatalFlushTimeoutMs; + }, + })); } export function init(config) { + exitOnUnhandledRejection = config.exitOnUnhandledRejection !== false; + fatalFlushTimeoutMs = + typeof config.fatalFlushTimeoutMs === "number" && + Number.isFinite(config.fatalFlushTimeoutMs) && + config.fatalFlushTimeoutMs >= 0 + ? config.fatalFlushTimeoutMs + : FATAL_FLUSH_TIMEOUT_MS; coreInit({ ...config, platform: config.platform ?? "node" }); installGlobalHandlers(); } @@ -25,9 +63,13 @@ export { identify, trackEvent, coreTrackError as trackError }; export function getConfig() { return getConfigOrNull(); } +/** + * Generic (req, res, next) middleware. Times duration until the **response** + * finishes (`finish`/`close`), not the request body stream. + */ export function middleware(opts) { const trackRequestBody = opts?.trackRequestBody ?? false; - return function telemetryMiddleware(req, _res, next) { + return function telemetryMiddleware(req, res, next) { const cfg = getConfigOrNull(); if (!cfg) { next(); @@ -49,15 +91,17 @@ export function middleware(opts) { ...(trackRequestBody && req.body ? { body: req.body } : {}), }); }; - const reqWithOn = req; - if (typeof reqWithOn.on === "function") { - reqWithOn.on("end", done); - reqWithOn.on("close", done); + const resWithOn = res; + const canWatchResponse = typeof resWithOn?.on === "function"; + if (canWatchResponse) { + resWithOn.on("finish", done); + resWithOn.on("close", done); } - else { - next(); + next(); + // Frameworks without a Node response emitter (e.g. plain objects / some + // Fastify adapters): record once after next(), never call next twice. + if (!canWatchResponse) { done(); } - next(); }; } diff --git a/packages/telemetry-node/package.json b/packages/telemetry-node/package.json index cf0db903..f691eeba 100644 --- a/packages/telemetry-node/package.json +++ b/packages/telemetry-node/package.json @@ -1,6 +1,6 @@ { "name": "@telemetry-tracker/node", - "version": "1.3.0", + "version": "1.4.0", "description": "Telemetry Tracker SDK for Node.js: global error handlers, request middleware.", "license": "MIT", "repository": { @@ -24,7 +24,7 @@ "build": "tsc", "dev": "tsc --watch", "test": "vitest run", - "prepublishOnly": "pnpm run build" + "prepublishOnly": "node ../../scripts/assert-sdk-publish-context.mjs && pnpm run build" }, "keywords": [ "telemetry", diff --git a/packages/telemetry-node/src/fatal.test.ts b/packages/telemetry-node/src/fatal.test.ts index 32404a73..5702eede 100644 --- a/packages/telemetry-node/src/fatal.test.ts +++ b/packages/telemetry-node/src/fatal.test.ts @@ -1,7 +1,10 @@ -import { describe, expect, it } from "vitest"; -import { flushFatalError } from "./fatal.js"; +import { describe, expect, it, vi, afterEach } from "vitest"; +import { flushFatalError, resetFatalFlushStateForTests } from "./fatal.js"; describe("flushFatalError", () => { + afterEach(() => { + resetFatalFlushStateForTests(); + }); it("exits only after the error ingest settles", async () => { const order: string[] = []; let releaseIngest: () => void = () => {}; @@ -40,4 +43,96 @@ describe("flushFatalError", () => { await new Promise((resolve) => setTimeout(resolve, 50)); expect(exited).toBe(true); }); + + it.each([ + [null, "null"], + [undefined, "undefined"], + ["string-throw", "string-throw"], + [42, "42"], + [{ a: 1 }, '{"a":1}'], + ])("normalizes %j into a reportable Error before ingest", async (thrown, message) => { + const ingest = vi.fn(async () => {}); + const exit = vi.fn(); + flushFatalError(thrown, "uncaughtException", { + ingest, + exit, + timeoutMs: 50, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(ingest).toHaveBeenCalledWith( + expect.objectContaining({ message }), + expect.objectContaining({ source: "uncaughtException" }) + ); + await new Promise((resolve) => setTimeout(resolve, 40)); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("clears the flush timeout after ingest settles", async () => { + const clearSpy = vi.spyOn(globalThis, "clearTimeout"); + let releaseIngest!: () => void; + flushFatalError(new Error("boom"), "uncaughtException", { + ingest: () => + new Promise((resolve) => { + releaseIngest = resolve; + }), + exit: () => {}, + timeoutMs: 5000, + }); + releaseIngest(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(clearSpy).toHaveBeenCalled(); + clearSpy.mockRestore(); + }); + + it("ignores a second fatal flush while one is in progress (strict rejections)", async () => { + const ingest = vi.fn(async () => {}); + const exit = vi.fn(); + flushFatalError(new Error("a"), "unhandledRejection", { + ingest, + exit, + timeoutMs: 50, + }); + flushFatalError(new Error("a"), "uncaughtException", { + ingest, + exit, + timeoutMs: 50, + }); + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(ingest).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledTimes(1); + }); + + it.each([ + ["frozen", (e: Error) => Object.freeze(e)], + ["sealed", (e: Error) => Object.seal(e)], + ["non-extensible", (e: Error) => Object.preventExtensions(e)], + ] as const)("ingests %s Error then exits 1", async (_label, lock) => { + const ingest = vi.fn(async () => {}); + const exit = vi.fn(); + flushFatalError(lock(new Error("locked")), "uncaughtException", { + ingest, + exit, + timeoutMs: 50, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(ingest).toHaveBeenCalledWith( + expect.objectContaining({ message: "locked" }), + expect.objectContaining({ source: "uncaughtException" }) + ); + await new Promise((resolve) => setTimeout(resolve, 40)); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("still exits 1 when ingest throws synchronously", async () => { + const exit = vi.fn(); + flushFatalError(new Error("boom"), "uncaughtException", { + ingest: () => { + throw new TypeError("cannot add property"); + }, + exit, + timeoutMs: 50, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(exit).toHaveBeenCalledWith(1); + }); }); diff --git a/packages/telemetry-node/src/fatal.ts b/packages/telemetry-node/src/fatal.ts index f93ac0e2..299e19e3 100644 --- a/packages/telemetry-node/src/fatal.ts +++ b/packages/telemetry-node/src/fatal.ts @@ -1,21 +1,87 @@ +import { toReportableError } from "@telemetry-tracker/core"; + /** Bound how long a crashing process waits for the error ingest request. */ export const FATAL_FLUSH_TIMEOUT_MS = 2000; +export type FatalFlushDeps = { + ingest: (error: Error, context: Record) => Promise; + exit: (code: number) => void; + /** Override the default {@link FATAL_FLUSH_TIMEOUT_MS}. */ + timeoutMs?: number; +}; + +/** Prevents double exit when strict mode raises rejection as uncaughtException too. */ +let fatalFlushInProgress = false; + +function safeExit(deps: FatalFlushDeps): void { + try { + deps.exit(1); + } catch { + // ignore — never throw from the crash handler + } +} + +/** + * Report a fatal error, wait up to `timeoutMs` for ingest, then exit(1). + * Accepts any thrown value (null/undefined/primitives/objects). + * Clears the timeout when ingest settles so the timer does not keep the process alive. + * Telemetry-internal failures must not escape (would become Node exit code 7). + */ export function flushFatalError( - err: Error, + thrown: unknown, source: string, - deps: { - ingest: (error: Error, context: Record) => Promise; - exit: (code: number) => void; - timeoutMs?: number; - } + deps: FatalFlushDeps ): void { - const timeoutMs = deps.timeoutMs ?? FATAL_FLUSH_TIMEOUT_MS; - const flush = deps.ingest(err, { source }).catch(() => undefined); - const timer = new Promise((resolve) => { - setTimeout(resolve, timeoutMs); - }); - void Promise.race([flush, timer]).finally(() => { - deps.exit(1); - }); + try { + if (fatalFlushInProgress) return; + fatalFlushInProgress = true; + + const timeoutMs = deps.timeoutMs ?? FATAL_FLUSH_TIMEOUT_MS; + let settled = false; + let timeoutId: ReturnType | undefined; + + let flush: Promise; + try { + const err = toReportableError(thrown); + let pending: Promise; + try { + pending = Promise.resolve(deps.ingest(err, { source })); + } catch { + pending = Promise.resolve(); + } + flush = pending.catch(() => undefined); + } catch { + flush = Promise.resolve(); + } + + const timer = new Promise((resolve) => { + timeoutId = setTimeout(resolve, timeoutMs); + const handle = timeoutId as { unref?: () => void }; + if (typeof handle.unref === "function") handle.unref(); + }); + + const finish = () => { + if (settled) return; + settled = true; + fatalFlushInProgress = false; + if (timeoutId !== undefined) { + try { + clearTimeout(timeoutId); + } catch { + // ignore + } + } + safeExit(deps); + }; + + void Promise.race([flush, timer]).then(finish, finish); + } catch { + fatalFlushInProgress = false; + safeExit(deps); + } +} + +/** @internal test helper */ +export function resetFatalFlushStateForTests(): void { + fatalFlushInProgress = false; } diff --git a/packages/telemetry-node/src/index.test.ts b/packages/telemetry-node/src/index.test.ts new file mode 100644 index 00000000..02dddefe --- /dev/null +++ b/packages/telemetry-node/src/index.test.ts @@ -0,0 +1,216 @@ +import { EventEmitter } from "node:events"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { shutdown as coreShutdown } from "@telemetry-tracker/core"; +import { + createUncaughtExceptionHandler, + createUnhandledRejectionHandler, + init, + middleware, +} from "./index.js"; +import { resetFatalFlushStateForTests } from "./fatal.js"; + +afterEach(() => { + resetFatalFlushStateForTests(); +}); + +describe("createUncaughtExceptionHandler", () => { + it("flushes with source uncaughtException then exits", async () => { + const order: string[] = []; + let resolveIngest!: () => void; + const ingest = vi.fn( + () => + new Promise((resolve) => { + resolveIngest = () => { + order.push("ingest"); + resolve(); + }; + }) + ); + const exit = vi.fn((code: number) => { + order.push(`exit:${code}`); + }); + + createUncaughtExceptionHandler({ ingest, exit })(new Error("boom")); + await new Promise((r) => setTimeout(r, 10)); + expect(order).toEqual([]); + expect(ingest).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ source: "uncaughtException" }) + ); + resolveIngest(); + await new Promise((r) => setTimeout(r, 10)); + expect(order).toEqual(["ingest", "exit:1"]); + }); + + it.each([null, undefined, "x", 7, { e: true }])( + "ingests non-Error throw %j then exits 1", + async (thrown) => { + const ingest = vi.fn(async () => {}); + const exit = vi.fn(); + createUncaughtExceptionHandler({ ingest, exit, timeoutMs: 50 })(thrown); + await new Promise((r) => setTimeout(r, 20)); + expect(ingest).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ source: "uncaughtException" }) + ); + await new Promise((r) => setTimeout(r, 40)); + expect(exit).toHaveBeenCalledWith(1); + } + ); + + it.each([ + ["frozen", (e: Error) => Object.freeze(e)], + ["sealed", (e: Error) => Object.seal(e)], + ["non-extensible", (e: Error) => Object.preventExtensions(e)], + ] as const)("ingests %s Error throw then exits 1", async (_label, lock) => { + const ingest = vi.fn(async () => {}); + const exit = vi.fn(); + createUncaughtExceptionHandler({ ingest, exit, timeoutMs: 50 })( + lock(new Error("locked")) + ); + await new Promise((r) => setTimeout(r, 20)); + expect(ingest).toHaveBeenCalledWith( + expect.objectContaining({ message: "locked" }), + expect.objectContaining({ source: "uncaughtException" }) + ); + await new Promise((r) => setTimeout(r, 40)); + expect(exit).toHaveBeenCalledWith(1); + }); +}); + +describe("createUnhandledRejectionHandler", () => { + it("flushes and exits by default (TT-019)", async () => { + const order: string[] = []; + let resolveIngest!: () => void; + const ingest = vi.fn( + () => + new Promise((resolve) => { + resolveIngest = () => { + order.push("ingest"); + resolve(); + }; + }) + ); + const trackError = vi.fn(); + const exit = vi.fn((code: number) => { + order.push(`exit:${code}`); + }); + + createUnhandledRejectionHandler({ + exitOnUnhandledRejection: true, + ingest, + trackError, + exit, + })(new Error("rejected")); + + expect(trackError).not.toHaveBeenCalled(); + expect(ingest).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ source: "unhandledRejection" }) + ); + resolveIngest(); + await new Promise((r) => setTimeout(r, 10)); + expect(order).toEqual(["ingest", "exit:1"]); + }); + + it("only reports when exitOnUnhandledRejection is false", async () => { + const ingest = vi.fn(async () => {}); + const trackError = vi.fn(); + const exit = vi.fn(); + + createUnhandledRejectionHandler({ + exitOnUnhandledRejection: false, + ingest, + trackError, + exit, + })("string-reason"); + + expect(ingest).not.toHaveBeenCalled(); + expect(exit).not.toHaveBeenCalled(); + expect(trackError).toHaveBeenCalledWith( + expect.objectContaining({ message: "string-reason" }), + { source: "unhandledRejection" } + ); + }); +}); + +describe("middleware", () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: async () => "" }); + + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockClear(); + init({ + ingestUrl: "http://localhost:4318", + app: "qa-node", + apiKey: "test", + batchInterval: 0, + environment: "test", + }); + }); + + afterEach(() => { + coreShutdown(); + vi.unstubAllGlobals(); + }); + + + async function waitForRequestEvents(): Promise< + Array<{ name?: string; properties?: Record }> + > { + await new Promise((r) => setTimeout(r, 30)); + return fetchMock.mock.calls + .filter((call) => String(call[0]).includes("/ingest/event")) + .map((call) => JSON.parse(String((call[1] as RequestInit).body))); + } + + it("calls next exactly once when req has no on (TT-020 / #632)", async () => { + let nextCount = 0; + const res = new EventEmitter(); + middleware()({ method: "GET", url: "/x" }, res, () => { + nextCount += 1; + }); + expect(nextCount).toBe(1); + res.emit("finish"); + const events = await waitForRequestEvents(); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "$request", + properties: expect.objectContaining({ method: "GET", url: "/x" }), + }), + ]) + ); + }); + + it("calls next exactly once for plain-object res without emitters", async () => { + let nextCount = 0; + middleware()({ method: "GET", url: "/plain" }, {}, () => { + nextCount += 1; + }); + expect(nextCount).toBe(1); + const events = await waitForRequestEvents(); + expect(events.some((e) => e.name === "$request")).toBe(true); + }); + + it("records duration_ms until response finish, not request end (TT-020)", async () => { + const res = new EventEmitter(); + // Simulate a body parser that already ended the request stream before the handler. + const req = Object.assign(new EventEmitter(), { + method: "POST", + url: "/slow", + body: { a: 1 }, + }); + middleware()(req, res, () => {}); + req.emit("end"); + await new Promise((r) => setTimeout(r, 80)); + res.emit("finish"); + + const events = await waitForRequestEvents(); + const request = events.find((e) => e.name === "$request"); + expect(request).toBeTruthy(); + const duration = Number(request!.properties!.duration_ms); + expect(duration).toBeGreaterThanOrEqual(70); + expect(duration).toBeLessThan(500); + }); +}); diff --git a/packages/telemetry-node/src/index.ts b/packages/telemetry-node/src/index.ts index 20dc9cda..c8dfe1c6 100644 --- a/packages/telemetry-node/src/index.ts +++ b/packages/telemetry-node/src/index.ts @@ -5,38 +5,108 @@ import { trackError as coreTrackError, ingestError, getConfigOrNull, + toReportableError, type TelemetryConfig, } from "@telemetry-tracker/core"; -import { flushFatalError } from "./fatal.js"; +import { FATAL_FLUSH_TIMEOUT_MS, flushFatalError } from "./fatal.js"; export type TelemetryNodeConfig = TelemetryConfig & { app: string; platform?: string; + /** + * When true (default), report an unhandled rejection, flush ingest, then + * `process.exit(1)` — matching Node’s default crash since v15. Set `false` + * to only report and keep the process running (legacy 1.3.x behaviour). + * + * With `node --unhandled-rejections=strict`, rejections are also raised as + * uncaught exceptions; the SDK de-dupes a single fatal flush per error. + */ + exitOnUnhandledRejection?: boolean; + /** + * Max ms to wait for fatal error ingest before exiting. Default 2000. + * The timeout is cleared when ingest settles and does not keep the process alive. + */ + fatalFlushTimeoutMs?: number; }; export { FATAL_FLUSH_TIMEOUT_MS, flushFatalError } from "./fatal.js"; let installed = false; +let exitOnUnhandledRejection = true; +let fatalFlushTimeoutMs = FATAL_FLUSH_TIMEOUT_MS; + +/** @internal Exported for unit tests. */ +export function createUncaughtExceptionHandler(deps: { + ingest: (error: Error, context: Record) => Promise; + exit: (code: number) => void; + timeoutMs?: number; +}): (err: unknown) => void { + return (err: unknown) => { + flushFatalError(err, "uncaughtException", deps); + }; +} + +/** @internal Exported for unit tests. */ +export function createUnhandledRejectionHandler(deps: { + exitOnUnhandledRejection: boolean; + ingest: (error: Error, context: Record) => Promise; + trackError: (error: Error, context: Record) => void; + exit: (code: number) => void; + timeoutMs?: number; +}): (reason: unknown) => void { + return (reason: unknown) => { + const err = toReportableError(reason); + if (deps.exitOnUnhandledRejection) { + flushFatalError(err, "unhandledRejection", { + ingest: deps.ingest, + exit: deps.exit, + timeoutMs: deps.timeoutMs, + }); + return; + } + deps.trackError(err, { source: "unhandledRejection" }); + }; +} function installGlobalHandlers(): void { if (installed) return; installed = true; - process.on("uncaughtException", (err: Error) => { - flushFatalError(err, "uncaughtException", { + process.on( + "uncaughtException", + createUncaughtExceptionHandler({ ingest: ingestError, exit: (code) => process.exit(code), - }); - }); - - process.on("unhandledRejection", (reason: unknown) => { - const err = - reason instanceof Error ? reason : new Error(String(reason)); - coreTrackError(err, { source: "unhandledRejection" }); - }); + get timeoutMs() { + return fatalFlushTimeoutMs; + }, + }) + ); + + process.on( + "unhandledRejection", + createUnhandledRejectionHandler({ + get exitOnUnhandledRejection() { + return exitOnUnhandledRejection; + }, + ingest: ingestError, + trackError: coreTrackError, + exit: (code) => process.exit(code), + get timeoutMs() { + return fatalFlushTimeoutMs; + }, + }) + ); } export function init(config: TelemetryNodeConfig): void { + exitOnUnhandledRejection = config.exitOnUnhandledRejection !== false; + fatalFlushTimeoutMs = + typeof config.fatalFlushTimeoutMs === "number" && + Number.isFinite(config.fatalFlushTimeoutMs) && + config.fatalFlushTimeoutMs >= 0 + ? config.fatalFlushTimeoutMs + : FATAL_FLUSH_TIMEOUT_MS; coreInit({ ...config, platform: config.platform ?? "node" }); installGlobalHandlers(); } @@ -47,11 +117,19 @@ export function getConfig(): TelemetryConfig | null { return getConfigOrNull(); } +type MaybeEmitter = { + on?(event: string, listener: () => void): void; +}; + +/** + * Generic (req, res, next) middleware. Times duration until the **response** + * finishes (`finish`/`close`), not the request body stream. + */ export function middleware(opts?: { trackRequestBody?: boolean }) { const trackRequestBody = opts?.trackRequestBody ?? false; return function telemetryMiddleware( req: { method?: string; url?: string; body?: unknown }, - _res: unknown, + res: MaybeEmitter | unknown, next: () => void ): void { const cfg = getConfigOrNull(); @@ -74,14 +152,20 @@ export function middleware(opts?: { trackRequestBody?: boolean }) { ...(trackRequestBody && req.body ? { body: req.body } : {}), }); }; - const reqWithOn = req as { on?(event: string, listener: () => void): void }; - if (typeof reqWithOn.on === "function") { - reqWithOn.on("end", done); - reqWithOn.on("close", done); - } else { - next(); - done(); + + const resWithOn = res as MaybeEmitter; + const canWatchResponse = typeof resWithOn?.on === "function"; + if (canWatchResponse) { + resWithOn.on!("finish", done); + resWithOn.on!("close", done); } + next(); + + // Frameworks without a Node response emitter (e.g. plain objects / some + // Fastify adapters): record once after next(), never call next twice. + if (!canWatchResponse) { + done(); + } }; } diff --git a/packages/telemetry-react-native/package.json b/packages/telemetry-react-native/package.json index e9d38a78..f69552df 100644 --- a/packages/telemetry-react-native/package.json +++ b/packages/telemetry-react-native/package.json @@ -23,7 +23,7 @@ "scripts": { "build": "tsc", "dev": "tsc --watch", - "prepublishOnly": "pnpm run build" + "prepublishOnly": "node ../../scripts/assert-sdk-publish-context.mjs && pnpm run build" }, "keywords": [ "telemetry", diff --git a/packages/telemetry-vite-plugin/package.json b/packages/telemetry-vite-plugin/package.json index 169c501c..9c90e1f0 100644 --- a/packages/telemetry-vite-plugin/package.json +++ b/packages/telemetry-vite-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@telemetry-tracker/vite-plugin", - "version": "1.0.0", + "version": "1.1.0", "description": "Vite plugin to upload source maps to Telemetry Tracker after build.", "license": "MIT", "repository": { @@ -24,7 +24,7 @@ "build": "tsc", "dev": "tsc --watch", "test": "vitest run", - "prepublishOnly": "pnpm run build" + "prepublishOnly": "node ../../scripts/assert-sdk-publish-context.mjs && pnpm run build" }, "keywords": [ "telemetry", diff --git a/packages/telemetry-vite-plugin/src/upload.test.ts b/packages/telemetry-vite-plugin/src/upload.test.ts index dba7ec98..8001b2f9 100644 --- a/packages/telemetry-vite-plugin/src/upload.test.ts +++ b/packages/telemetry-vite-plugin/src/upload.test.ts @@ -120,6 +120,23 @@ describe("bundleFileForSourceMap", () => { "https://example.com/foo.abc123.js" ); }); + + it("resolves maps moved to a sibling folder via relative sourceMappingURL (TT-018)", () => { + const outDir = "/app/dist"; + const bundlePath = `${outDir}/assets/index-azjItrWU.js`; + const mapPath = `${outDir}/maps/index-azjItrWU.js.map`; + expect( + bundleFileForSourceMap(mapPath, [ + { + filePath: bundlePath, + source: "//# sourceMappingURL=../maps/index-azjItrWU.js.map\n", + }, + ]) + ).toBe(bundlePath); + expect(bundleUrlForMapFile(bundlePath, outDir, "https://cdn.example.test")).toBe( + "https://cdn.example.test/assets/index-azjItrWU.js" + ); + }); }); describe("findMapFiles", () => { diff --git a/scripts/assert-sdk-publish-context.mjs b/scripts/assert-sdk-publish-context.mjs new file mode 100644 index 00000000..cf0b50a3 --- /dev/null +++ b/scripts/assert-sdk-publish-context.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node +/** + * prepublishOnly gate for @telemetry-tracker/* packages. + * Blocks accidental `npm publish` / `pnpm publish` from a package folder + * (which would skip release guards and could ship workspace:* deps). + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + assertNoWorkspaceProtocolDeps, + assertSdkReleasePublishEnv, +} from "./lib/publish-guards.mjs"; + +assertSdkReleasePublishEnv(); + +const pkg = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8")); +assertNoWorkspaceProtocolDeps(pkg); diff --git a/scripts/lib/publish-guards.mjs b/scripts/lib/publish-guards.mjs new file mode 100644 index 00000000..dab3fe56 --- /dev/null +++ b/scripts/lib/publish-guards.mjs @@ -0,0 +1,228 @@ +/** + * Pre-flight checks for SDK npm publishes. + * Used by scripts/publish-packages.mjs so releases only run from a clean, + * tagged origin/main checkout that can be traced to an exact commit (via gitHead). + */ +import { execFileSync } from "node:child_process"; + +export function git(args, cwd) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +/** Fail when the working tree has uncommitted changes. */ +export function assertCleanWorkingTree(cwd, { statusPorcelain } = {}) { + const status = + statusPorcelain ?? + execFileSync("git", ["status", "--porcelain"], { + cwd, + encoding: "utf8", + }); + if (status.trim().length > 0) { + throw new Error( + `Refusing to publish: working tree is dirty.\n` + + `SDK publishes must run from a clean checkout of a release tag.\n` + + status.trim() + ); + } +} + +/** + * Fail unless HEAD has at least one tag. Returns the primary tag name and SHA. + * Prefer an exact tag pointing at HEAD (not an ancestor-only describe). + */ +export function assertHeadIsTagged(cwd, { headSha, tagsAtHead } = {}) { + const sha = + headSha ?? + execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim(); + const tags = + tagsAtHead ?? + execFileSync("git", ["tag", "--points-at", "HEAD"], { + cwd, + encoding: "utf8", + }) + .split("\n") + .map((t) => t.trim()) + .filter(Boolean); + + if (tags.length === 0) { + throw new Error( + `Refusing to publish: HEAD ${sha.slice(0, 12)} is not tagged.\n` + + `Create and push SDK release tags (e.g. sdk-core-v1.5.0) on the origin/main ` + + `release commit, then publish from that checkout so npm gitHead matches the tag.` + ); + } + + return { sha, tags }; +} + +/** + * Expected release tags for a package version on HEAD. + * Accepts `sdk--v` or `@`. + */ +export function expectedReleaseTags(packageName, version, folderAlias) { + return [`sdk-${folderAlias}-v${version}`, `${packageName}@${version}`]; +} + +/** Fail unless HEAD carries a tag that matches this package version. */ +export function assertPackageReleaseTag({ + tags, + packageName, + version, + folderAlias, +}) { + const candidates = expectedReleaseTags(packageName, version, folderAlias); + if (!candidates.some((t) => tags.includes(t))) { + throw new Error( + `Refusing to publish ${packageName}@${version}: HEAD is missing a matching release tag.\n` + + `Expected one of: ${candidates.join(", ")}\n` + + `Tags on HEAD: ${tags.length ? tags.join(", ") : "(none)"}` + ); + } +} + +/** Fail unless HEAD is exactly origin/main (the production release commit). */ +export function assertHeadMatchesOriginMain( + cwd, + { headSha, originMainSha } = {} +) { + const head = + headSha ?? + execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim(); + let main = originMainSha; + if (main == null) { + try { + main = execFileSync("git", ["rev-parse", "origin/main"], { + cwd, + encoding: "utf8", + }).trim(); + } catch { + throw new Error( + `Refusing to publish: could not resolve origin/main.\n` + + `Fetch main first: git fetch origin main` + ); + } + } + if (head !== main) { + throw new Error( + `Refusing to publish: HEAD ${head.slice(0, 12)} is not origin/main (${main.slice(0, 12)}).\n` + + `SDK npm publishes must run from the tagged release commit on main.` + ); + } + return { head, main }; +} + +/** + * Fail unless each required tag exists on the remote pointing at HEAD. + * `remoteTagMap` is Map (injectable for tests). + */ +export function assertTagsPushedToOrigin({ + tags, + headSha, + remoteTagMap, +}) { + const missing = []; + const mismatched = []; + for (const tag of tags) { + const remoteSha = remoteTagMap.get(tag); + if (!remoteSha) { + missing.push(tag); + continue; + } + if (remoteSha !== headSha) { + mismatched.push(`${tag}→${remoteSha.slice(0, 12)}`); + } + } + if (missing.length || mismatched.length) { + const parts = []; + if (missing.length) parts.push(`missing on origin: ${missing.join(", ")}`); + if (mismatched.length) { + parts.push(`pointing at another commit: ${mismatched.join(", ")}`); + } + throw new Error( + `Refusing to publish: release tags are not pushed to origin at HEAD ${headSha.slice(0, 12)}.\n` + + parts.join("\n") + ); + } +} + +/** Parse `git ls-remote --tags origin` output into Map. */ +export function parseLsRemoteTags(stdout) { + const map = new Map(); + for (const line of String(stdout).split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const [sha, ref] = trimmed.split(/\s+/); + if (!sha || !ref?.startsWith("refs/tags/")) continue; + // Prefer peeled annotated tags (^{}) when present. + const name = ref.replace(/^refs\/tags\//, "").replace(/\^\{\}$/, ""); + const isPeeled = ref.endsWith("^{}"); + if (isPeeled || !map.has(name)) { + map.set(name, sha); + } + } + return map; +} + +export function loadOriginTagMap(cwd) { + const stdout = execFileSync("git", ["ls-remote", "--tags", "origin"], { + cwd, + encoding: "utf8", + }); + return parseLsRemoteTags(stdout); +} + +/** Reject package.json that still has workspace: protocol deps (unsafe for npm). */ +export function assertNoWorkspaceProtocolDeps(pkg) { + const sections = ["dependencies", "optionalDependencies", "peerDependencies"]; + const bad = []; + for (const section of sections) { + const deps = pkg[section]; + if (!deps || typeof deps !== "object") continue; + for (const [name, range] of Object.entries(deps)) { + if (typeof range === "string" && range.startsWith("workspace:")) { + bad.push(`${section}.${name}=${range}`); + } + } + } + if (bad.length) { + throw new Error( + `Refusing to publish ${pkg.name ?? "package"}: workspace: protocol deps must be rewritten first.\n` + + bad.join("\n") + ); + } +} + +/** --allow-dirty is dry-run only; never for a real publish. */ +export function assertAllowDirtyPolicy({ dryRun, allowDirty }) { + if (allowDirty && !dryRun) { + throw new Error( + `Refusing to publish: --allow-dirty cannot bypass safety checks for a real publish.\n` + + `Use --dry-run --allow-dirty only for local dry runs.` + ); + } +} + +/** Stamp npm's gitHead field so the published tarball traces to this commit. */ +export function stampGitHead(pkg, sha) { + return { ...pkg, gitHead: sha }; +} + +/** + * Env gate for package prepublishOnly — set by publish-packages.mjs only. + * Prevents `pnpm publish` / `npm publish` from a package folder bypassing guards. + */ +export const SDK_RELEASE_PUBLISH_ENV = "TELEMETRY_SDK_RELEASE_PUBLISH"; + +export function assertSdkReleasePublishEnv(env = process.env) { + if (env[SDK_RELEASE_PUBLISH_ENV] !== "1") { + throw new Error( + `Refusing to publish: direct npm/pnpm publish from a package folder is blocked.\n` + + `Use the root script from a clean tagged origin/main checkout:\n` + + ` pnpm publish:packages -- --only=core,node,vite-plugin` + ); + } +} diff --git a/scripts/lib/publish-guards.test.mjs b/scripts/lib/publish-guards.test.mjs new file mode 100644 index 00000000..f3337711 --- /dev/null +++ b/scripts/lib/publish-guards.test.mjs @@ -0,0 +1,204 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + assertAllowDirtyPolicy, + assertCleanWorkingTree, + assertHeadIsTagged, + assertHeadMatchesOriginMain, + assertNoWorkspaceProtocolDeps, + assertPackageReleaseTag, + assertSdkReleasePublishEnv, + assertTagsPushedToOrigin, + expectedReleaseTags, + parseLsRemoteTags, + stampGitHead, +} from "./publish-guards.mjs"; + +describe("assertCleanWorkingTree", () => { + it("passes when status is empty", () => { + assert.doesNotThrow(() => + assertCleanWorkingTree("/tmp", { statusPorcelain: "" }) + ); + }); + + it("throws when status is dirty", () => { + assert.throws( + () => + assertCleanWorkingTree("/tmp", { + statusPorcelain: " M packages/telemetry-node/package.json\n", + }), + /working tree is dirty/ + ); + }); +}); + +describe("assertHeadIsTagged", () => { + it("returns sha and tags when HEAD is tagged", () => { + const result = assertHeadIsTagged("/tmp", { + headSha: "abc123", + tagsAtHead: ["sdk-core-v1.5.0", "sdk-node-v1.4.0"], + }); + assert.deepEqual(result, { + sha: "abc123", + tags: ["sdk-core-v1.5.0", "sdk-node-v1.4.0"], + }); + }); + + it("throws when HEAD has no tags", () => { + assert.throws( + () => assertHeadIsTagged("/tmp", { headSha: "deadbeef", tagsAtHead: [] }), + /is not tagged/ + ); + }); +}); + +describe("assertPackageReleaseTag", () => { + it("accepts sdk-alias-vVERSION or name@VERSION", () => { + assert.doesNotThrow(() => + assertPackageReleaseTag({ + tags: ["sdk-core-v1.5.0"], + packageName: "@telemetry-tracker/core", + version: "1.5.0", + folderAlias: "core", + }) + ); + assert.doesNotThrow(() => + assertPackageReleaseTag({ + tags: ["@telemetry-tracker/node@1.4.0"], + packageName: "@telemetry-tracker/node", + version: "1.4.0", + folderAlias: "node", + }) + ); + }); + + it("throws when version tag is missing", () => { + assert.throws( + () => + assertPackageReleaseTag({ + tags: ["sdk-core-v1.4.0"], + packageName: "@telemetry-tracker/core", + version: "1.5.0", + folderAlias: "core", + }), + /missing a matching release tag/ + ); + }); + + it("lists expected tag names", () => { + assert.deepEqual(expectedReleaseTags("@telemetry-tracker/core", "1.5.0", "core"), [ + "sdk-core-v1.5.0", + "@telemetry-tracker/core@1.5.0", + ]); + }); +}); + +describe("assertHeadMatchesOriginMain", () => { + it("passes when HEAD equals origin/main", () => { + assert.deepEqual( + assertHeadMatchesOriginMain("/tmp", { + headSha: "aaa", + originMainSha: "aaa", + }), + { head: "aaa", main: "aaa" } + ); + }); + + it("throws when HEAD differs from origin/main", () => { + assert.throws( + () => + assertHeadMatchesOriginMain("/tmp", { + headSha: "aaa", + originMainSha: "bbb", + }), + /is not origin\/main/ + ); + }); +}); + +describe("assertTagsPushedToOrigin", () => { + it("passes when remote tags match HEAD", () => { + assert.doesNotThrow(() => + assertTagsPushedToOrigin({ + tags: ["sdk-core-v1.5.0"], + headSha: "abc", + remoteTagMap: new Map([["sdk-core-v1.5.0", "abc"]]), + }) + ); + }); + + it("throws when tags are missing or mismatched on origin", () => { + assert.throws( + () => + assertTagsPushedToOrigin({ + tags: ["sdk-core-v1.5.0", "sdk-node-v1.4.0"], + headSha: "abc", + remoteTagMap: new Map([["sdk-core-v1.5.0", "zzz"]]), + }), + /not pushed to origin/ + ); + }); +}); + +describe("parseLsRemoteTags", () => { + it("prefers peeled annotated tag SHAs", () => { + const map = parseLsRemoteTags(` +aaa\trefs/tags/sdk-core-v1.5.0 +bbb\trefs/tags/sdk-core-v1.5.0^{} +`); + assert.equal(map.get("sdk-core-v1.5.0"), "bbb"); + }); +}); + +describe("assertNoWorkspaceProtocolDeps", () => { + it("passes without workspace: ranges", () => { + assert.doesNotThrow(() => + assertNoWorkspaceProtocolDeps({ + name: "@telemetry-tracker/node", + dependencies: { "@telemetry-tracker/core": "^1.5.0" }, + }) + ); + }); + + it("throws when workspace: remains", () => { + assert.throws( + () => + assertNoWorkspaceProtocolDeps({ + name: "@telemetry-tracker/node", + dependencies: { "@telemetry-tracker/core": "workspace:*" }, + }), + /workspace: protocol/ + ); + }); +}); + +describe("assertAllowDirtyPolicy", () => { + it("allows dirty only with dry-run", () => { + assert.doesNotThrow(() => + assertAllowDirtyPolicy({ dryRun: true, allowDirty: true }) + ); + assert.throws( + () => assertAllowDirtyPolicy({ dryRun: false, allowDirty: true }), + /cannot bypass safety checks for a real publish/ + ); + }); +}); + +describe("assertSdkReleasePublishEnv", () => { + it("requires TELEMETRY_SDK_RELEASE_PUBLISH=1", () => { + assert.throws(() => assertSdkReleasePublishEnv({}), /direct npm\/pnpm publish/); + assert.doesNotThrow(() => + assertSdkReleasePublishEnv({ TELEMETRY_SDK_RELEASE_PUBLISH: "1" }) + ); + }); +}); + +describe("stampGitHead", () => { + it("adds gitHead without dropping other fields", () => { + assert.deepEqual(stampGitHead({ name: "x", version: "1.0.0" }, "abc"), { + name: "x", + version: "1.0.0", + gitHead: "abc", + }); + }); +}); diff --git a/scripts/publish-packages.mjs b/scripts/publish-packages.mjs index 21aa306a..7c5bda2d 100644 --- a/scripts/publish-packages.mjs +++ b/scripts/publish-packages.mjs @@ -1,25 +1,58 @@ #!/usr/bin/env node /** * Publish SDK packages to npm in order: telemetry-core first, then packages that depend on it. - * Temporarily replaces workspace:* with ^version for telemetry-core so the published tarball resolves from npm. + * + * Releases must run from a **clean, tagged origin/main** checkout. The script stamps `gitHead` + * on each published package.json so the tarball traces to the exact commit. Package + * `prepublishOnly` blocks direct folder publishes unless this script sets + * TELEMETRY_SDK_RELEASE_PUBLISH=1. + * + * Tag each package version on the release commit, e.g.: + * sdk-core-v1.5.0 sdk-node-v1.4.0 sdk-vite-plugin-v1.1.0 + * (or `@telemetry-tracker/core@1.5.0`, …), push tags, then: + * pnpm publish:packages -- --only=core,node,vite-plugin --otp=123456 */ import { readFileSync, writeFileSync } from "fs"; import { join } from "path"; import { fileURLToPath } from "url"; import { execFileSync } from "child_process"; +import { + SDK_RELEASE_PUBLISH_ENV, + assertAllowDirtyPolicy, + assertCleanWorkingTree, + assertHeadIsTagged, + assertHeadMatchesOriginMain, + assertNoWorkspaceProtocolDeps, + assertPackageReleaseTag, + assertTagsPushedToOrigin, + loadOriginTagMap, + stampGitHead, +} from "./lib/publish-guards.mjs"; const root = join(fileURLToPath(import.meta.url), "..", ".."); const packagesDir = join(root, "packages"); -const coreName = "telemetry-core"; // folder name -const coreDep = "@telemetry-tracker/core"; // package name for dependency -const dependents = [ +const coreName = "telemetry-core"; +const coreDep = "@telemetry-tracker/core"; +const allDependents = [ "telemetry-next", "telemetry-node", "telemetry-react-native", "telemetry-vite-plugin", ]; +const folderByAlias = { + core: "telemetry-core", + node: "telemetry-node", + next: "telemetry-next", + "react-native": "telemetry-react-native", + "vite-plugin": "telemetry-vite-plugin", +}; + +const aliasByFolder = Object.fromEntries( + Object.entries(folderByAlias).map(([alias, folder]) => [folder, alias]) +); + function readJson(path) { return JSON.parse(readFileSync(path, "utf8")); } @@ -27,8 +60,6 @@ function writeJson(path, obj) { writeFileSync(path, JSON.stringify(obj, null, 2) + "\n"); } -/** Run npm/pnpm via execFile. On Windows, shell+PATHEXT resolves .cmd/.exe; - * args stay argv-safe (no string-concat OTP / flags into a shell command). */ function runTool(name, args, options) { return execFileSync(name, args, { ...options, @@ -36,16 +67,8 @@ function runTool(name, args, options) { }); } -function runOptional(file, args, cwd = root) { - try { - runTool(file, args, { cwd, stdio: "inherit" }); - return true; - } catch { - return false; - } -} - const dryRun = process.argv.includes("--dry-run"); +const allowDirty = process.argv.includes("--allow-dirty"); const otpArg = process.argv.find((a) => a.startsWith("--otp=")); const otp = otpArg?.slice("--otp=".length) ?? ""; if (otpArg && !/^\d{6,8}$/.test(otp)) { @@ -53,7 +76,19 @@ if (otpArg && !/^\d{6,8}$/.test(otp)) { process.exit(1); } +const onlyArg = process.argv.find((a) => a.startsWith("--only=")); +const onlyFolders = onlyArg + ? onlyArg + .slice("--only=".length) + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((alias) => folderByAlias[alias] ?? alias) + : null; + function publishArgs() { + // Native git checks cannot run while we temporarily rewrite workspace:* → ^version. + // Traceability: clean+tagged origin/main guards + stamped gitHead. const args = ["publish", "--access", "public", "--no-git-checks"]; if (dryRun) args.push("--dry-run"); if (otp) args.push(`--otp=${otp}`); @@ -69,7 +104,7 @@ function assertNpmAuth() { npm publish failed: not logged in to https://registry.npmjs.org/ 1. npm login - 2. Ensure your npm user can publish the @telemetry-tracker scope (create org at npmjs.com/org/create if needed) + 2. Ensure your npm user can publish the @telemetry-tracker scope 3. pnpm publish:packages -- --otp=123456 (if 2FA is enabled) Dry run (no login): pnpm publish:dry @@ -78,34 +113,170 @@ Dry run (no login): pnpm publish:dry } } +function resolvePublishSet() { + if (!onlyFolders) { + return { publishCore: true, dependents: allDependents }; + } + const publishCore = onlyFolders.includes(coreName); + const dependents = allDependents.filter((name) => onlyFolders.includes(name)); + const unknown = onlyFolders.filter( + (name) => name !== coreName && !allDependents.includes(name) + ); + if (unknown.length) { + console.error(`Unknown --only package(s): ${unknown.join(", ")}`); + process.exit(1); + } + return { publishCore, dependents }; +} + +function folderAlias(folderName) { + return aliasByFolder[folderName] ?? folderName.replace(/^telemetry-/, ""); +} + +function fail(message) { + console.error(`\n${message}\n`); + process.exit(1); +} + +try { + assertAllowDirtyPolicy({ dryRun, allowDirty }); +} catch (err) { + fail(err instanceof Error ? err.message : String(err)); +} + assertNpmAuth(); -// 1. Get core version +const skipSafetyForDryDirty = dryRun && allowDirty; +let headSha = "unknown"; +let headTags = []; + +if (skipSafetyForDryDirty) { + console.warn( + "\nWARNING: --dry-run --allow-dirty skips clean/tag/origin checks (local dry run only).\n" + ); +} else { + try { + assertCleanWorkingTree(root); + ({ sha: headSha, tags: headTags } = assertHeadIsTagged(root)); + assertHeadMatchesOriginMain(root, { headSha }); + const remoteTagMap = loadOriginTagMap(root); + // Validate remote presence for every tag on HEAD that looks like an SDK release tag. + const sdkTagsOnHead = headTags.filter( + (t) => t.startsWith("sdk-") || t.startsWith("@telemetry-tracker/") + ); + assertTagsPushedToOrigin({ + tags: sdkTagsOnHead.length ? sdkTagsOnHead : headTags, + headSha, + remoteTagMap, + }); + } catch (err) { + fail(err instanceof Error ? err.message : String(err)); + } + console.log(`\nPublish from origin/main ${headSha}`); + console.log(`Tags on HEAD: ${headTags.join(", ")}\n`); +} + +const { publishCore, dependents } = resolvePublishSet(); +const packagesToPublish = [ + ...(publishCore ? [coreName] : []), + ...dependents, +]; + const corePkgPath = join(packagesDir, coreName, "package.json"); const coreVersion = readJson(corePkgPath).version; -console.log(`\n${coreName} version: ${coreVersion}\n`); +console.log(`${coreName} version: ${coreVersion}\n`); -// 2. Publish telemetry-core (continue if already published) -const corePublished = runOptional("pnpm", publishArgs(), join(packagesDir, coreName)); -if (!corePublished) { - console.log(`\n(${coreName} publish failed or skipped, continuing with dependents…)\n`); +// Per-package version tags must exist on HEAD (skipped only for dry-run --allow-dirty). +if (!skipSafetyForDryDirty) { + for (const folderName of packagesToPublish) { + const pkg = readJson(join(packagesDir, folderName, "package.json")); + try { + assertPackageReleaseTag({ + tags: headTags, + packageName: pkg.name, + version: pkg.version, + folderAlias: folderAlias(folderName), + }); + } catch (err) { + fail(err instanceof Error ? err.message : String(err)); + } + } } -// 3. Publish dependents (patch deps, publish, restore); continue on failure (e.g. already published) -for (const name of dependents) { - const pkgPath = join(packagesDir, name, "package.json"); - const pkg = readJson(pkgPath); - const original = { ...pkg, dependencies: { ...pkg.dependencies } }; - if (pkg.dependencies && typeof pkg.dependencies[coreDep] === "string") { - pkg.dependencies[coreDep] = `^${coreVersion}`; - writeJson(pkgPath, pkg); +function publishPackage(folderName, mutatePkg) { + const pkgPath = join(packagesDir, folderName, "package.json"); + const original = readJson(pkgPath); + const working = mutatePkg + ? mutatePkg(structuredClone(original)) + : structuredClone(original); + const toPublish = + headSha === "unknown" ? working : stampGitHead(working, headSha); + + try { + assertNoWorkspaceProtocolDeps(toPublish); + } catch (err) { + writeJson(pkgPath, original); + throw err; } + + writeJson(pkgPath, toPublish); + const env = { + ...process.env, + [SDK_RELEASE_PUBLISH_ENV]: "1", + }; try { - const ok = runOptional("pnpm", publishArgs(), join(packagesDir, name)); - if (!ok) console.log(`(${name} publish failed or skipped.)\n`); + runTool("pnpm", publishArgs(), { + cwd: join(packagesDir, folderName), + stdio: "inherit", + env, + }); + return true; + } catch { + return false; } finally { writeJson(pkgPath, original); } } +let failed = false; + +if (publishCore) { + let ok = false; + try { + ok = publishPackage(coreName); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + ok = false; + } + if (!ok) { + fail( + `${coreName} publish failed; aborting so dependents (e.g. node) are not published against a missing core.` + ); + } +} + +for (const name of dependents) { + let ok = false; + try { + ok = publishPackage(name, (pkg) => { + if (pkg.dependencies && typeof pkg.dependencies[coreDep] === "string") { + pkg.dependencies[coreDep] = `^${coreVersion}`; + } + return pkg; + }); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + ok = false; + } + if (!ok) { + console.error(`\n${name} publish failed.\n`); + failed = true; + break; + } +} + +if (failed) { + process.exit(1); +} + console.log("\nDone.");