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":"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"}
\ 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