From 0cea05763c39733f053f49908b7f6e20468ac200 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sat, 26 Sep 2026 09:24:23 +0000
Subject: [PATCH 1/3] fix(sdk): prepare core 1.5 / node 1.4 / vite-plugin 1.1
Publish ingestError in core 1.5.0 for fatal flushes. Node 1.4.0 flushes
uncaughtException and unhandledRejection before exit (TT-012/TT-019),
times middleware duration on response finish with a single next()
(TT-020/#632), and documents exitOnUnhandledRejection. Vite plugin
1.1.0 publishes sourceMappingURL-based bundle_url (TT-018). Harden
publish-packages for clean tagged checkouts with stamped gitHead.
Refs #711 #718 #719 #720 #632
Co-authored-by: Sanja Malovic
---
CHANGELOG.md | 6 +
CONTRIBUTING.md | 12 +-
README.md | 2 +-
apps/dashboard/app/docs/node/page.tsx | 11 +-
.../app/error-tracking/nodejs/page.tsx | 6 +-
docs/sdk-node.md | 32 ++--
package.json | 3 +-
packages/telemetry-core/dist/version.d.ts | 2 +-
packages/telemetry-core/dist/version.js | 2 +-
packages/telemetry-core/package.json | 2 +-
packages/telemetry-core/src/index.test.ts | 16 ++
packages/telemetry-core/src/version.ts | 2 +-
packages/telemetry-node/dist/index.d.ts | 27 ++-
packages/telemetry-node/dist/index.js | 66 +++++--
packages/telemetry-node/package.json | 2 +-
packages/telemetry-node/src/index.test.ts | 176 ++++++++++++++++++
packages/telemetry-node/src/index.ts | 95 ++++++++--
packages/telemetry-vite-plugin/package.json | 2 +-
.../telemetry-vite-plugin/src/upload.test.ts | 17 ++
scripts/lib/publish-guards.mjs | 65 +++++++
scripts/lib/publish-guards.test.mjs | 55 ++++++
scripts/publish-packages.mjs | 120 ++++++++++--
22 files changed, 634 insertions(+), 87 deletions(-)
create mode 100644 packages/telemetry-node/src/index.test.ts
create mode 100644 scripts/lib/publish-guards.mjs
create mode 100644 scripts/lib/publish-guards.test.mjs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 33c4e755..f67f4f67 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.
@@ -23,6 +27,8 @@ Contributors: add user-facing changes under **[Unreleased]** in your PR to `deve
### Changed
+- **SDK publish** — `pnpm publish:packages` requires a clean, tagged checkout, stamps `gitHead` on each tarball, and supports `--only=core,node,vite-plugin`
+
### Database
---
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bb2413f5..348a6989 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -97,7 +97,17 @@ 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 a release tag** (so `gitHead` on npm matches the commit):
+
+```bash
+git fetch --tags
+git checkout sdk-core-v1.5.0 # or a combined SDK release tag that points at the publish commit
+pnpm publish:packages -- --only=core,node,vite-plugin --otp=123456
+# dry run:
+pnpm publish:dry -- --only=core,node,vite-plugin --allow-dirty
+```
+
+The publish script refuses a dirty tree or an untagged `HEAD`, stamps `gitHead` on each package, and rewrites `workspace:*` → `^` for the published tarball only. 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). 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..d20c2941 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,18 @@ 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. |
+
## 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 (≤ 2s), then the process exits with code 1.
+- **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.
-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 +65,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..e7480f45 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,8 @@
"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/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..1b6acd0b 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": {
diff --git a/packages/telemetry-core/src/index.test.ts b/packages/telemetry-core/src/index.test.ts
index 73c80d77..acff76f1 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,
@@ -63,4 +64,19 @@ 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" },
+ });
+ });
});
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-node/dist/index.d.ts b/packages/telemetry-node/dist/index.d.ts
index 36930830..01a2f202 100644
--- a/packages/telemetry-node/dist/index.d.ts
+++ b/packages/telemetry-node/dist/index.d.ts
@@ -2,15 +2,40 @@ 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).
+ */
+ exitOnUnhandledRejection?: boolean;
};
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;
+}): (err: Error) => 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;
+}): (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..8e010eb7 100644
--- a/packages/telemetry-node/dist/index.js
+++ b/packages/telemetry-node/dist/index.js
@@ -2,22 +2,46 @@ import { init as coreInit, identify, trackEvent, trackError as coreTrackError, i
import { flushFatalError } from "./fatal.js";
export { FATAL_FLUSH_TIMEOUT_MS, flushFatalError } from "./fatal.js";
let installed = false;
+let exitOnUnhandledRejection = true;
+/** @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 = reason instanceof Error ? reason : new Error(String(reason));
+ if (deps.exitOnUnhandledRejection) {
+ flushFatalError(err, "unhandledRejection", {
+ ingest: deps.ingest,
+ exit: deps.exit,
+ });
+ 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),
+ }));
+ process.on("unhandledRejection", createUnhandledRejectionHandler({
+ get exitOnUnhandledRejection() {
+ return exitOnUnhandledRejection;
+ },
+ ingest: ingestError,
+ trackError: coreTrackError,
+ exit: (code) => process.exit(code),
+ }));
}
export function init(config) {
+ exitOnUnhandledRejection = config.exitOnUnhandledRejection !== false;
coreInit({ ...config, platform: config.platform ?? "node" });
installGlobalHandlers();
}
@@ -25,9 +49,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 +77,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..acb306fc 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": {
diff --git a/packages/telemetry-node/src/index.test.ts b/packages/telemetry-node/src/index.test.ts
new file mode 100644
index 00000000..fb20e70d
--- /dev/null
+++ b/packages/telemetry-node/src/index.test.ts
@@ -0,0 +1,176 @@
+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";
+
+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"]);
+ });
+});
+
+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..0a491478 100644
--- a/packages/telemetry-node/src/index.ts
+++ b/packages/telemetry-node/src/index.ts
@@ -12,31 +12,76 @@ import { 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).
+ */
+ exitOnUnhandledRejection?: boolean;
};
export { FATAL_FLUSH_TIMEOUT_MS, flushFatalError } from "./fatal.js";
let installed = false;
+let exitOnUnhandledRejection = true;
+
+/** @internal Exported for unit tests. */
+export function createUncaughtExceptionHandler(deps: {
+ ingest: (error: Error, context: Record) => Promise;
+ exit: (code: number) => void;
+}): (err: Error) => void {
+ return (err: Error) => {
+ 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;
+}): (reason: unknown) => void {
+ return (reason: unknown) => {
+ const err = reason instanceof Error ? reason : new Error(String(reason));
+ if (deps.exitOnUnhandledRejection) {
+ flushFatalError(err, "unhandledRejection", {
+ ingest: deps.ingest,
+ exit: deps.exit,
+ });
+ 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" });
- });
+ })
+ );
+
+ process.on(
+ "unhandledRejection",
+ createUnhandledRejectionHandler({
+ get exitOnUnhandledRejection() {
+ return exitOnUnhandledRejection;
+ },
+ ingest: ingestError,
+ trackError: coreTrackError,
+ exit: (code) => process.exit(code),
+ })
+ );
}
export function init(config: TelemetryNodeConfig): void {
+ exitOnUnhandledRejection = config.exitOnUnhandledRejection !== false;
coreInit({ ...config, platform: config.platform ?? "node" });
installGlobalHandlers();
}
@@ -47,11 +92,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 +127,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-vite-plugin/package.json b/packages/telemetry-vite-plugin/package.json
index 169c501c..4a9b8967 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": {
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/lib/publish-guards.mjs b/scripts/lib/publish-guards.mjs
new file mode 100644
index 00000000..800a80f5
--- /dev/null
+++ b/scripts/lib/publish-guards.mjs
@@ -0,0 +1,65 @@
+/**
+ * Pre-flight checks for SDK npm publishes.
+ * Used by scripts/publish-packages.mjs so releases only run from a clean,
+ * tagged 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 an SDK release tag (e.g. sdk-core-v1.5.0) on a clean commit, ` +
+ `then publish from that checkout so npm gitHead matches the tag.`
+ );
+ }
+
+ return { sha, tags };
+}
+
+/** Stamp npm's gitHead field so the published tarball traces to this commit. */
+export function stampGitHead(pkg, sha) {
+ return { ...pkg, gitHead: sha };
+}
diff --git a/scripts/lib/publish-guards.test.mjs b/scripts/lib/publish-guards.test.mjs
new file mode 100644
index 00000000..2fb4b156
--- /dev/null
+++ b/scripts/lib/publish-guards.test.mjs
@@ -0,0 +1,55 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import {
+ assertCleanWorkingTree,
+ assertHeadIsTagged,
+ 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("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..9052bc79 100644
--- a/scripts/publish-packages.mjs
+++ b/scripts/publish-packages.mjs
@@ -1,25 +1,45 @@
#!/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** checkout. The script stamps `gitHead` on each
+ * published package.json so the npm tarball traces to the exact commit (pnpm temporarily
+ * rewrites workspace:* deps, so native `npm publish` git checks cannot be used).
+ *
+ * Examples:
+ * pnpm publish:packages -- --dry-run
+ * 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 {
+ assertCleanWorkingTree,
+ assertHeadIsTagged,
+ 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 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",
+};
+
function readJson(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
@@ -46,6 +66,7 @@ function runOptional(file, args, cwd = root) {
}
const dryRun = process.argv.includes("--dry-run");
+const skipGitGuards = 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 +74,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 comes from assertCleanWorkingTree + assertHeadIsTagged + stamped gitHead.
const args = ["publish", "--access", "public", "--no-git-checks"];
if (dryRun) args.push("--dry-run");
if (otp) args.push(`--otp=${otp}`);
@@ -78,34 +111,83 @@ 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 };
+}
+
assertNpmAuth();
+let headSha = "unknown";
+let headTags = [];
+if (skipGitGuards) {
+ console.warn(
+ "\nWARNING: --allow-dirty skips clean/tag guards. Do not use for production publishes.\n"
+ );
+} else {
+ try {
+ assertCleanWorkingTree(root);
+ ({ sha: headSha, tags: headTags } = assertHeadIsTagged(root));
+ } catch (err) {
+ console.error(`\n${err instanceof Error ? err.message : err}\n`);
+ process.exit(1);
+ }
+ console.log(`\nPublish from tagged commit ${headSha}`);
+ console.log(`Tags: ${headTags.join(", ")}\n`);
+}
+
+const { publishCore, dependents } = resolvePublishSet();
+
// 1. Get core version
const corePkgPath = join(packagesDir, coreName, "package.json");
const coreVersion = readJson(corePkgPath).version;
console.log(`\n${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`);
-}
-
-// 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);
+ writeJson(pkgPath, toPublish);
try {
- const ok = runOptional("pnpm", publishArgs(), join(packagesDir, name));
- if (!ok) console.log(`(${name} publish failed or skipped.)\n`);
+ const ok = runOptional("pnpm", publishArgs(), join(packagesDir, folderName));
+ if (!ok) console.log(`(${folderName} publish failed or skipped.)\n`);
+ return ok;
} finally {
writeJson(pkgPath, original);
}
}
+// 2. Publish telemetry-core (continue if already published)
+if (publishCore) {
+ const corePublished = publishPackage(coreName);
+ if (!corePublished) {
+ console.log(`\n(${coreName} publish failed or skipped, continuing with dependents…)\n`);
+ }
+}
+
+// 3. Publish dependents (patch deps, stamp gitHead, publish, restore)
+for (const name of dependents) {
+ publishPackage(name, (pkg) => {
+ if (pkg.dependencies && typeof pkg.dependencies[coreDep] === "string") {
+ pkg.dependencies[coreDep] = `^${coreVersion}`;
+ }
+ return pkg;
+ });
+}
+
console.log("\nDone.");
From e9b0a9dec32619105f96b4b680d64324ac730729 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sat, 26 Sep 2026 11:36:42 +0000
Subject: [PATCH 2/3] fix(sdk): harden fatal throws and publish guards for #722
Normalize null/undefined/primitive/object throws for ingest and Node
fatal handlers; await in-flight trackError sends on rethrow; make fatal
flush timeout configurable and clear/unref the timer. Require
origin/main + per-package version tags for publish, block --allow-dirty
and direct folder publishes, abort if core fails, and run publish-guard
tests in CI.
Refs #711 #718 #719 #720 #632
Co-authored-by: Sanja Malovic
---
CHANGELOG.md | 2 +-
CONTRIBUTING.md | 19 +-
.../app/error-tracking/nodejs/page.tsx | 6 +-
docs/sdk-node.md | 5 +-
package.json | 2 +-
packages/telemetry-core/dist/index.d.ts | 11 +-
packages/telemetry-core/dist/index.d.ts.map | 2 +-
packages/telemetry-core/dist/index.js | 28 ++-
.../dist/to-reportable-error.d.ts | 3 +
.../dist/to-reportable-error.d.ts.map | 1 +
.../dist/to-reportable-error.js | 30 +++
packages/telemetry-core/package.json | 2 +-
packages/telemetry-core/src/index.test.ts | 42 +++++
packages/telemetry-core/src/index.ts | 37 +++-
.../src/to-reportable-error.test.ts | 24 +++
.../telemetry-core/src/to-reportable-error.ts | 25 +++
packages/telemetry-next/package.json | 2 +-
packages/telemetry-node/dist/fatal.d.ts | 13 +-
packages/telemetry-node/dist/fatal.js | 36 +++-
packages/telemetry-node/dist/index.d.ts | 12 +-
packages/telemetry-node/dist/index.js | 20 +-
packages/telemetry-node/package.json | 2 +-
packages/telemetry-node/src/fatal.test.ts | 65 ++++++-
packages/telemetry-node/src/fatal.ts | 53 +++++-
packages/telemetry-node/src/index.test.ts | 16 ++
packages/telemetry-node/src/index.ts | 33 +++-
packages/telemetry-react-native/package.json | 2 +-
packages/telemetry-vite-plugin/package.json | 2 +-
scripts/assert-sdk-publish-context.mjs | 17 ++
scripts/lib/publish-guards.mjs | 169 ++++++++++++++++-
scripts/lib/publish-guards.test.mjs | 149 +++++++++++++++
scripts/publish-packages.mjs | 175 +++++++++++++-----
32 files changed, 893 insertions(+), 112 deletions(-)
create mode 100644 packages/telemetry-core/dist/to-reportable-error.d.ts
create mode 100644 packages/telemetry-core/dist/to-reportable-error.d.ts.map
create mode 100644 packages/telemetry-core/dist/to-reportable-error.js
create mode 100644 packages/telemetry-core/src/to-reportable-error.test.ts
create mode 100644 packages/telemetry-core/src/to-reportable-error.ts
create mode 100644 scripts/assert-sdk-publish-context.mjs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f67f4f67..c4a93a8c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -27,7 +27,7 @@ Contributors: add user-facing changes under **[Unreleased]** in your PR to `deve
### Changed
-- **SDK publish** — `pnpm publish:packages` requires a clean, tagged checkout, stamps `gitHead` on each tarball, and supports `--only=core,node,vite-plugin`
+- **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 348a6989..23f32512 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -97,17 +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): from a **clean checkout of a release tag** (so `gitHead` on npm matches the commit):
+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 --tags
-git checkout sdk-core-v1.5.0 # or a combined SDK release tag that points at the publish commit
+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
-# dry run:
+# 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 or an untagged `HEAD`, stamps `gitHead` on each package, and rewrites `workspace:*` → `^` for the published tarball only. After the first publish under the new scope, deprecate the legacy `@tacko/telemetry-*` packages with a message pointing to `@telemetry-tracker/*`.
+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/apps/dashboard/app/error-tracking/nodejs/page.tsx b/apps/dashboard/app/error-tracking/nodejs/page.tsx
index df31a55b..1a755ca9 100644
--- a/apps/dashboard/app/error-tracking/nodejs/page.tsx
+++ b/apps/dashboard/app/error-tracking/nodejs/page.tsx
@@ -72,8 +72,10 @@ export default function NodeJsErrorTrackingPage() {
@telemetry-tracker/node wraps core for servers. After init(),
it installs handlers for uncaughtException and{" "}
unhandledRejection, flushes the error to ingest, then exits with code 1
- (Node’s default). Optional request middleware sends a $request event per
- HTTP call with duration_ms until the response finishes.
+ (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 d20c2941..6e93f859 100644
--- a/docs/sdk-node.md
+++ b/docs/sdk-node.md
@@ -47,14 +47,17 @@ Config extends [telemetry-core](sdk-core.md#initconfig) and requires `app`; `pla
| 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" }`, ingest is flushed (≤ 2s), then the process exits with code 1.
+- **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 for extra context.
## Request middleware
diff --git a/package.json b/package.json
index e7480f45..95a11ea6 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"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",
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..b189293f 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;AAwCzB,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,CAyCf;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..e3932d57 100644
--- a/packages/telemetry-core/dist/index.js
+++ b/packages/telemetry-core/dist/index.js
@@ -2,10 +2,14 @@ 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");
+/** In-flight ingest promises so a later fatal flush can await trackError(e); throw e. */
+const inFlightIngest = new WeakMap();
const ANON_STORAGE_KEY = "tacko_telemetry_anon_id";
let anonymousId = null;
let fallbackIdSeq = 0;
@@ -414,12 +418,15 @@ 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]) {
+ const err = toReportableError(error);
+ const existing = inFlightIngest.get(err);
+ if (existing)
+ return existing;
+ if (err[REPORTED]) {
return Promise.resolve();
}
- let message = err instanceof Error ? err.message : err.message;
- let stack = err instanceof Error ? err.stack : err.stack;
+ let message = err.message;
+ let stack = err.stack;
let scrubbedContext = context ?? undefined;
const scrubOpts = resolveClientPiiScrub(cfg);
if (scrubOpts) {
@@ -430,15 +437,20 @@ export function ingestError(error, context) {
scrubbedContext = scrubPiiRecord(scrubbedContext, scrubOpts);
}
}
- if (err instanceof Error)
- err[REPORTED] = true;
- return send("/ingest/error", {
+ err[REPORTED] = true;
+ const pending = send("/ingest/error", {
message,
stack: stack ?? undefined,
context: scrubbedContext,
user_id: userId ?? undefined,
session_id: sessionId ?? undefined,
- }).catch(() => { });
+ })
+ .catch(() => { })
+ .finally(() => {
+ inFlightIngest.delete(err);
+ });
+ inFlightIngest.set(err, pending);
+ return pending;
}
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/package.json b/packages/telemetry-core/package.json
index 1b6acd0b..30be960d 100644
--- a/packages/telemetry-core/package.json
+++ b/packages/telemetry-core/package.json
@@ -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 acff76f1..a8bba977 100644
--- a/packages/telemetry-core/src/index.test.ts
+++ b/packages/telemetry-core/src/index.test.ts
@@ -79,4 +79,46 @@ describe("ingest fetch", () => {
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);
+ });
});
diff --git a/packages/telemetry-core/src/index.ts b/packages/telemetry-core/src/index.ts
index 477950d1..1a8e9825 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,
@@ -24,6 +26,8 @@ export {
} from "./web-vitals.js";
const REPORTED = Symbol.for("telemetry.reported");
+/** In-flight ingest promises so a later fatal flush can await trackError(e); throw e. */
+const inFlightIngest = new WeakMap