first commit
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
# @wrnexus/tracking
|
||||
|
||||
> Error tracking for WrNexus apps: capture exceptions manually or via middleware and fan them out to pluggable sinks.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/tracking` is a small, server-side error-capture layer. You create a
|
||||
tracker with one or more **sinks**, then feed it errors — either manually with
|
||||
`tracker.capture(err, context)` or automatically by mounting `tracker.middleware()`
|
||||
in your request pipeline. A `consoleSink` is included; forwarding to Sentry,
|
||||
Datadog, or any other backend is just a matter of writing a tiny sink. Reach for
|
||||
it when you want a single, sink-agnostic place to route application errors. Sinks
|
||||
run best-effort — a throwing sink never breaks the request.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/tracking
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## API
|
||||
|
||||
### `createTracker(options?): Tracker`
|
||||
|
||||
Creates a tracker. `TrackerOptions`:
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------------ | ------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `sinks` | `ErrorSink[]` | Initial sinks to fan events out to. Defaults to `[]`. |
|
||||
| `now` | `() => number` | Clock used for `event.timestamp` (epoch ms). Defaults to `Date.now`. |
|
||||
| `beforeSend` | `(event: ErrorEvent) => ErrorEvent \| null` | Scrub/enrich an event before it reaches any sink. Return `null` to drop it. |
|
||||
|
||||
The returned `Tracker`:
|
||||
|
||||
| Member | Signature | Description |
|
||||
| ------------ | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `capture` | `(error: unknown, context?: Record<string, unknown>) => Promise<void>` | Normalizes any thrown value into an `Error`, builds an `ErrorEvent`, runs `beforeSend`, then dispatches to all sinks. Non-`Error` values are wrapped in an `Error` named `NonError`. |
|
||||
| `addSink` | `(sink: ErrorSink) => void` | Registers an additional sink at runtime. |
|
||||
| `middleware` | `() => Middleware` | Returns a WrNexus `Middleware` that captures any error thrown downstream, then re-throws it so the framework's error handler still produces the response. |
|
||||
|
||||
The middleware attaches this context to captured events:
|
||||
|
||||
```ts
|
||||
{ method: ctx.req.method, path: ctx.url.pathname, requestId: ctx.locals.requestId }
|
||||
```
|
||||
|
||||
### `consoleSink: ErrorSink`
|
||||
|
||||
A built-in sink that logs a compact one-line message via `console.error`, e.g.
|
||||
`[error] TypeError: cannot read x {"userId":42}`.
|
||||
|
||||
### Types
|
||||
|
||||
```ts
|
||||
interface ErrorEvent {
|
||||
error: Error;
|
||||
context: Record<string, unknown>; // request info, user id, tags…
|
||||
timestamp: number; // epoch ms
|
||||
}
|
||||
|
||||
interface ErrorSink {
|
||||
name?: string;
|
||||
capture(event: ErrorEvent): void | Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Manual capture:
|
||||
|
||||
```ts
|
||||
import { createTracker, consoleSink } from "@wrnexus/tracking";
|
||||
|
||||
const tracker = createTracker({ sinks: [consoleSink] });
|
||||
|
||||
try {
|
||||
await doWork();
|
||||
} catch (err) {
|
||||
await tracker.capture(err, { userId: 42, op: "doWork" });
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
As request middleware:
|
||||
|
||||
```ts
|
||||
import { createTracker, consoleSink } from "@wrnexus/tracking";
|
||||
|
||||
const tracker = createTracker({ sinks: [consoleSink] });
|
||||
|
||||
app.use(tracker.middleware()); // captures + re-throws downstream errors
|
||||
```
|
||||
|
||||
A custom sink with `beforeSend` scrubbing:
|
||||
|
||||
```ts
|
||||
import { createTracker, type ErrorSink } from "@wrnexus/tracking";
|
||||
|
||||
const sentrySink: ErrorSink = {
|
||||
name: "sentry",
|
||||
async capture(event) {
|
||||
await Sentry.captureException(event.error, { extra: event.context });
|
||||
},
|
||||
};
|
||||
|
||||
const tracker = createTracker({
|
||||
sinks: [sentrySink],
|
||||
beforeSend(event) {
|
||||
delete event.context.password; // scrub secrets
|
||||
return event; // return null to drop the event entirely
|
||||
},
|
||||
});
|
||||
|
||||
tracker.addSink(anotherSink); // add more sinks later
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- Runs on **Bun** only (Node is not supported).
|
||||
- Peer package: [`@wrnexus/core`](../core) — the `Context` and `Middleware` types
|
||||
used by `tracker.middleware()` come from there.
|
||||
- Sink dispatch is fire-and-forget-safe: all sinks run via `Promise.all`, and a
|
||||
sink that throws is swallowed so it can never break the app.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@wrnexus/tracking",
|
||||
"version": "0.2.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @wrnexus/tracking — error tracking with pluggable sinks. Capture exceptions
|
||||
* manually or via middleware, and fan them out to any sink (console by default;
|
||||
* write a small sink to forward to Sentry/Datadog/etc.).
|
||||
*
|
||||
* const tracker = createTracker({ sinks: [consoleSink] });
|
||||
* app-middleware: tracker.middleware() // captures + re-throws request errors
|
||||
* tracker.capture(err, { userId }); // manual
|
||||
*/
|
||||
|
||||
import type { Context, Middleware } from "@wrnexus/core";
|
||||
|
||||
export interface ErrorEvent {
|
||||
error: Error;
|
||||
/** Arbitrary structured context (request info, user id, tags…). */
|
||||
context: Record<string, unknown>;
|
||||
/** Epoch ms. */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface ErrorSink {
|
||||
name?: string;
|
||||
capture(event: ErrorEvent): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface Tracker {
|
||||
capture(error: unknown, context?: Record<string, unknown>): Promise<void>;
|
||||
addSink(sink: ErrorSink): void;
|
||||
/** Middleware that captures errors thrown downstream, then re-throws them. */
|
||||
middleware(): Middleware;
|
||||
}
|
||||
|
||||
export interface TrackerOptions {
|
||||
sinks?: ErrorSink[];
|
||||
now?: () => number;
|
||||
/** Scrub/enrich an event before it hits sinks (return null to drop it). */
|
||||
beforeSend?: (event: ErrorEvent) => ErrorEvent | null;
|
||||
}
|
||||
|
||||
/** A sink that logs a compact one-line error to the console. */
|
||||
export const consoleSink: ErrorSink = {
|
||||
name: "console",
|
||||
capture(event) {
|
||||
const ctx = Object.keys(event.context).length ? ` ${JSON.stringify(event.context)}` : "";
|
||||
console.error(`[error] ${event.error.name}: ${event.error.message}${ctx}`);
|
||||
},
|
||||
};
|
||||
|
||||
function toError(value: unknown): Error {
|
||||
if (value instanceof Error) return value;
|
||||
const err = new Error(typeof value === "string" ? value : JSON.stringify(value));
|
||||
err.name = "NonError";
|
||||
return err;
|
||||
}
|
||||
|
||||
export function createTracker(options: TrackerOptions = {}): Tracker {
|
||||
const sinks = [...(options.sinks ?? [])];
|
||||
const now = options.now ?? Date.now;
|
||||
|
||||
const capture: Tracker["capture"] = async (error, context = {}) => {
|
||||
let event: ErrorEvent | null = { error: toError(error), context, timestamp: now() };
|
||||
if (options.beforeSend) event = options.beforeSend(event);
|
||||
if (!event) return;
|
||||
await Promise.all(
|
||||
sinks.map(async (sink) => {
|
||||
try {
|
||||
await sink.capture(event as ErrorEvent);
|
||||
} catch {
|
||||
/* a failing sink must never break the app */
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
capture,
|
||||
addSink(sink) {
|
||||
sinks.push(sink);
|
||||
},
|
||||
middleware(): Middleware {
|
||||
return async (ctx: Context, next) => {
|
||||
try {
|
||||
return await next();
|
||||
} catch (error) {
|
||||
await capture(error, {
|
||||
method: ctx.req.method,
|
||||
path: ctx.url.pathname,
|
||||
requestId: ctx.locals.requestId,
|
||||
});
|
||||
throw error; // let the framework's error handler produce the response
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { createContext } from "@wrnexus/core";
|
||||
import { createTracker, type ErrorEvent, type ErrorSink } from "../src/index.ts";
|
||||
|
||||
function collectingSink(): ErrorSink & { events: ErrorEvent[] } {
|
||||
const events: ErrorEvent[] = [];
|
||||
return { name: "collect", events, capture: (e) => void events.push(e) };
|
||||
}
|
||||
|
||||
test("capture fans out to sinks with context + timestamp", async () => {
|
||||
const sink = collectingSink();
|
||||
const tracker = createTracker({ sinks: [sink], now: () => 123 });
|
||||
await tracker.capture(new Error("boom"), { userId: "u1" });
|
||||
expect(sink.events.length).toBe(1);
|
||||
expect(sink.events[0]!.error.message).toBe("boom");
|
||||
expect(sink.events[0]!.context.userId).toBe("u1");
|
||||
expect(sink.events[0]!.timestamp).toBe(123);
|
||||
});
|
||||
|
||||
test("non-Error values are wrapped", async () => {
|
||||
const sink = collectingSink();
|
||||
const tracker = createTracker({ sinks: [sink] });
|
||||
await tracker.capture("just a string");
|
||||
expect(sink.events[0]!.error.message).toBe("just a string");
|
||||
});
|
||||
|
||||
test("beforeSend can scrub or drop events", async () => {
|
||||
const sink = collectingSink();
|
||||
const tracker = createTracker({
|
||||
sinks: [sink],
|
||||
beforeSend: (e) => (e.context.secret ? null : e), // drop events tagged secret
|
||||
});
|
||||
await tracker.capture(new Error("a"), { secret: true });
|
||||
await tracker.capture(new Error("b"));
|
||||
expect(sink.events.map((e) => e.error.message)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
test("middleware captures a thrown request error and re-throws it", async () => {
|
||||
const sink = collectingSink();
|
||||
const tracker = createTracker({ sinks: [sink] });
|
||||
const url = new URL("http://x/api/boom");
|
||||
const ctx = createContext(new Request(url, { method: "POST" }), url);
|
||||
await expect(
|
||||
tracker.middleware()(ctx, () => {
|
||||
throw new Error("downstream");
|
||||
}),
|
||||
).rejects.toThrow("downstream");
|
||||
expect(sink.events[0]!.context).toMatchObject({ method: "POST", path: "/api/boom" });
|
||||
});
|
||||
|
||||
test("a failing sink never breaks capture", async () => {
|
||||
const bad: ErrorSink = {
|
||||
capture: () => {
|
||||
throw new Error("sink down");
|
||||
},
|
||||
};
|
||||
const good = collectingSink();
|
||||
const tracker = createTracker({ sinks: [bad, good] });
|
||||
await tracker.capture(new Error("x")); // must not throw
|
||||
expect(good.events.length).toBe(1);
|
||||
});
|
||||
Reference in New Issue
Block a user