diff --git a/.changeset/browser-support.md b/.changeset/browser-support.md new file mode 100644 index 0000000..787b834 --- /dev/null +++ b/.changeset/browser-support.md @@ -0,0 +1,5 @@ +--- +"agentcrumbs": minor +--- + +Add browser support via tshy `esmDialects`. Bundlers that respect the `"browser"` export condition (Vite, webpack, esbuild, Next.js) automatically resolve to the browser build. Same `"agentcrumbs"` import path — no separate entry point. Adds `configure()` API for enabling tracing in the browser. diff --git a/README.md b/README.md index f191bcc..8be35cb 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ All methods are documented in detail at [agentcrumbs.dev/docs/api](https://agent | Method | Purpose | | --- | --- | +| `configure(config)` | Enable tracing in the browser (no-op in Node.js) | | `trail(namespace)` | Create a trail function for a namespace | | `crumb(msg, data?, options?)` | Drop a crumb with message and optional data | | `crumb.scope(name, fn)` | Wrap a function with entry/exit/error tracking | @@ -104,9 +105,16 @@ All methods are documented in detail at [agentcrumbs.dev/docs/api](https://agent Mark crumb lines with `// @crumbs` (single line) or `// #region @crumbs` / `// #endregion @crumbs` (block) so they can be stripped before merge. See the [markers docs](https://agentcrumbs.dev/docs/markers) for details and examples. -## Environment variable +## Configuration -Everything is controlled by a single `AGENTCRUMBS` environment variable. +In Node.js, everything is controlled by a single `AGENTCRUMBS` environment variable. In the browser, use `configure()`: + +```typescript +import { configure } from "agentcrumbs"; // @crumbs +configure("*"); // @crumbs — enable all namespaces +``` + +### Environment variable (Node.js) | Value | Effect | | --- | --- | @@ -173,9 +181,10 @@ curl -X POST http://localhost:8374/crumb \ ## Runtime compatibility -Zero runtime dependencies. Node.js built-in modules only: `node:http`, `node:async_hooks`, `node:crypto`, `node:fs`, `node:util`. +Zero runtime dependencies. -Verified compatible with **Node.js 18+** and **Bun**. +- **Node.js 18+** and **Bun** — uses `node:async_hooks`, `node:crypto`, `node:fs`, `node:util` +- **Browsers** — Vite, webpack, esbuild, Next.js auto-resolve to the browser build via the `"browser"` export condition. Same `"agentcrumbs"` import path. Use `configure()` instead of the env var to enable tracing. See the [browser guide](https://agentcrumbs.dev/docs/guides/browser). ## Docs diff --git a/docs/content/docs/api/trail.mdx b/docs/content/docs/api/trail.mdx index 15bb73a..57b72ed 100644 --- a/docs/content/docs/api/trail.mdx +++ b/docs/content/docs/api/trail.mdx @@ -47,7 +47,7 @@ Returns a `TrailFn`, a callable function with additional methods: When a namespace is disabled, `trail()` returns a pre-built frozen noop function. There is no `if (enabled)` check on every call. The function itself IS the noop. ```typescript -// When AGENTCRUMBS is unset: +// When tracing is not enabled: const crumb = trail("my-service"); // returns frozen NOOP crumb("msg", { data }); // empty function, returns undefined crumb.scope("op", fn); // calls fn() directly diff --git a/docs/content/docs/config/env-var.mdx b/docs/content/docs/config/env-var.mdx index bff0832..aa739fa 100644 --- a/docs/content/docs/config/env-var.mdx +++ b/docs/content/docs/config/env-var.mdx @@ -3,7 +3,7 @@ title: "Environment variable" description: "Configure agentcrumbs with the AGENTCRUMBS env var" --- -Everything is controlled by a single `AGENTCRUMBS` environment variable. +In Node.js, everything is controlled by a single `AGENTCRUMBS` environment variable. In the browser, use [`configure()`](/guides/browser) instead. ## Shorthand values diff --git a/docs/content/docs/config/sinks.mdx b/docs/content/docs/config/sinks.mdx index de6d03d..b0bd71a 100644 --- a/docs/content/docs/config/sinks.mdx +++ b/docs/content/docs/config/sinks.mdx @@ -3,7 +3,7 @@ title: "Sinks" description: "Configure where crumbs are sent" --- -By default, crumbs are sent via HTTP to the collector and also printed to stderr. You can add custom sinks or replace the defaults. +By default, crumbs are sent via HTTP to the collector and also printed to the console. In Node.js, output goes to stderr with ANSI colors. In the browser, output goes to `console.debug()` with CSS styling. You can add custom sinks or replace the defaults. ## Custom sink @@ -32,7 +32,10 @@ import { HttpSink } from "agentcrumbs"; ### ConsoleSink -Pretty-printed stderr output. Added automatically when `AGENTCRUMBS` is set. +Pretty-printed console output. Added automatically when tracing is enabled. + +- **Node.js**: ANSI-colored output to stderr +- **Browser**: CSS-styled output via `console.debug()`, with `console.groupCollapsed()` for scopes and interactive object rendering in DevTools ```typescript import { ConsoleSink } from "agentcrumbs"; diff --git a/docs/content/docs/guides/browser.mdx b/docs/content/docs/guides/browser.mdx new file mode 100644 index 0000000..4f048e1 --- /dev/null +++ b/docs/content/docs/guides/browser.mdx @@ -0,0 +1,96 @@ +--- +title: "Browser" +description: "Use agentcrumbs in browser apps with the same import path" +--- + +agentcrumbs works in the browser with the same `"agentcrumbs"` import. Bundlers that respect the `"browser"` export condition (Vite, webpack, esbuild, Next.js) automatically resolve to the browser build. + +## Enable tracing + +Browsers don't have environment variables. Use `configure()` instead: + +```typescript +import { configure, trail } from "agentcrumbs"; // @crumbs + +configure("*"); // @crumbs — enable all namespaces + +const crumb = trail("ui"); // @crumbs +``` + +`configure()` accepts the same values as the `AGENTCRUMBS` env var: + +```typescript +// Enable all +configure("*"); // @crumbs + +// Namespace filter +configure("ui-*,api-*"); // @crumbs + +// Full config object +configure({ ns: "ui-*", app: "my-app", format: "pretty" }); // @crumbs +``` + +Call `configure()` before any `trail()` calls. A good place is your app's entry point. + +### Declarative fallback + +You can also set config on `globalThis` before importing agentcrumbs: + +```html + +``` + +## App name + +In the browser, the app name is resolved in this order: +1. `app` field from `configure()` config +2. `globalThis.__AGENTCRUMBS_APP__` +3. Fallback: `"browser"` + +## Console output + +In the browser, crumbs are written to `console.debug()` with CSS styling: + +- Namespace labels are color-coded +- Scope enter/exit use `console.groupCollapsed()` / `console.groupEnd()` for collapsible nesting +- Data objects are passed as additional arguments so DevTools renders them interactively + +When `format: "json"` is set, crumbs are written as JSON strings via `console.debug()`. + +## Collector support + +The browser build includes the HTTP sink, so crumbs are sent to the collector just like in Node.js. Start `agentcrumbs collect` on your dev machine and crumbs from both your server and browser flow to the same place. + +```bash +# Terminal: Start collector +agentcrumbs collect + +# Browser crumbs + server crumbs appear together +agentcrumbs tail --all-apps +``` + +The browser defaults to `http://localhost:8374/crumb`. Make sure CORS allows it, or the HTTP sink silently fails (crumbs still appear in the DevTools console). + +## Differences from Node.js + +| | Node.js | Browser | +|---|---------|---------| +| **Config** | `AGENTCRUMBS` env var | `configure()` call | +| **Console output** | ANSI-colored stderr | CSS-styled DevTools console | +| **Async context** | `AsyncLocalStorage` | Sync stack (single-threaded) | +| **Process ID** | `process.pid` | `0` | +| **Session file** | Reads `/tmp/agentcrumbs.session` | Skipped | +| **UUID** | `node:crypto` | Web Crypto API | +| **App fallback** | Nearest `package.json` name | `"browser"` | + +### Context isolation + +The browser uses a sync stack instead of `AsyncLocalStorage`. This works for all linear async flows. However, concurrent branches in `Promise.all` won't isolate context from each other. This is acceptable for debugging — just be aware that nested scopes inside `Promise.all` may share context. + +## configure() in Node.js + +`configure()` is exported from both builds so your code compiles in both environments. In Node.js it's a no-op — use the `AGENTCRUMBS` env var instead. diff --git a/docs/content/docs/guides/meta.json b/docs/content/docs/guides/meta.json index 812af1a..21fe36c 100644 --- a/docs/content/docs/guides/meta.json +++ b/docs/content/docs/guides/meta.json @@ -1,4 +1,4 @@ { "title": "Guides", - "pages": ["node-modules", "multi-service", "pr-reviewers", "cross-language"] + "pages": ["browser", "node-modules", "multi-service", "pr-reviewers", "cross-language"] } diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index 3b73b0e..7b2c932 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -31,6 +31,7 @@ Service C ──┘ (fire & forget) └── ~/.agentcrumbs/ --help` for detailed options on any command. ## Enable tracing +**Node.js:** Set the `AGENTCRUMBS` environment variable: + ```bash AGENTCRUMBS=1 node app.js # Enable all namespaces AGENTCRUMBS='{"ns":"auth-*"}' node app.js # Filter by namespace ``` -When `AGENTCRUMBS` is not set, `trail()` returns a frozen noop. No conditionals, no overhead. +**Browser:** Use `configure()` instead (no env vars in browsers): + +```typescript +import { configure, trail } from "agentcrumbs"; // @crumbs +configure("*"); // @crumbs — enable all namespaces +const crumb = trail("ui"); // @crumbs +``` + +Bundlers (Vite, webpack, esbuild, Next.js) auto-resolve to the browser build. Same import path. + +When tracing is not enabled, `trail()` returns a frozen noop. No conditionals, no overhead. ## App isolation @@ -156,7 +168,8 @@ agentcrumbs stats --all-apps # Per-app statistics 1. **Over-filtering queries** — Do NOT add `--ns` or `--match` filters to narrow results. Use `--limit` and `--cursor` to paginate instead. Filtering to one namespace hides cross-service bugs. If there are too many results, narrow the time window or reduce `--limit`, not the namespaces. 2. **Missing markers** — Every crumb line needs `// @crumbs` or a `#region @crumbs` block. Without them, `strip` can't clean up. -3. **Creating trail() in hot paths** — `trail()` parses the env var each call. Create once at module scope, use `child()` for per-request context. +3. **Creating trail() in hot paths** — `trail()` parses config each call. Create once at module scope, use `child()` for per-request context. +4. **Forgetting configure() in the browser** — In browser apps, call `configure("*")` before any `trail()` calls. Without it, all namespaces are disabled. 4. **No collector running** — Without `agentcrumbs collect`, crumbs go to stderr only and can't be queried. Start the collector before reproducing issues. ## Further discovery diff --git a/packages/agentcrumbs/skills/agentcrumbs/init/SKILL.md b/packages/agentcrumbs/skills/agentcrumbs/init/SKILL.md index febf3e7..f42d815 100644 --- a/packages/agentcrumbs/skills/agentcrumbs/init/SKILL.md +++ b/packages/agentcrumbs/skills/agentcrumbs/init/SKILL.md @@ -76,6 +76,13 @@ This is what gets stamped on every crumb as the `app` field. If the repo is a monorepo, use the root package name (not individual workspace packages — those become namespaces, not apps). +### Browser apps + +If the project has browser-side code (React, Vue, Svelte, etc.), include +browser-facing namespaces in the catalog. The same `"agentcrumbs"` import +works in the browser — bundlers auto-resolve to the browser build. Note +in the config that browser apps use `configure()` instead of the env var. + ### What to capture for each namespace For each namespace, record: @@ -141,7 +148,8 @@ production. ### CLI ```bash -AGENTCRUMBS=1 node app.js # enable tracing +AGENTCRUMBS=1 node app.js # enable tracing (Node.js) +configure("*") # enable tracing (browser — call before trail()) agentcrumbs collect # start collector agentcrumbs tail # live tail (scoped to this app) agentcrumbs query --since 5m # query recent crumbs (all namespaces) diff --git a/packages/agentcrumbs/src/context-browser.mts b/packages/agentcrumbs/src/context-browser.mts new file mode 100644 index 0000000..ca15d5f --- /dev/null +++ b/packages/agentcrumbs/src/context-browser.mts @@ -0,0 +1,39 @@ +export type DebugContext = { + namespace: string; + contextData: Record; + traceId: string; + depth: number; + sessionId?: string; +}; + +// Browser JS is single-threaded so a simple stack replaces AsyncLocalStorage. +// Limitation: concurrent Promise.all branches won't isolate context. +const contextStack: DebugContext[] = []; + +export function getContext(): DebugContext | undefined { + return contextStack[contextStack.length - 1]; +} + +export function runWithContext(ctx: DebugContext, fn: () => T): T { + contextStack.push(ctx); + try { + const result = fn(); + if (result instanceof Promise) { + return result.then( + (val) => { + contextStack.pop(); + return val; + }, + (err) => { + contextStack.pop(); + throw err; + }, + ) as T; + } + contextStack.pop(); + return result; + } catch (err) { + contextStack.pop(); + throw err; + } +} diff --git a/packages/agentcrumbs/src/env-browser.mts b/packages/agentcrumbs/src/env-browser.mts new file mode 100644 index 0000000..59b0162 --- /dev/null +++ b/packages/agentcrumbs/src/env-browser.mts @@ -0,0 +1,168 @@ +import type { AgentCrumbsConfig } from "./types.js"; + +const DEFAULT_PORT = 8374; + +type ParsedConfig = + | { enabled: false } + | { + enabled: true; + app?: string; + includes: RegExp[]; + excludes: RegExp[]; + port: number; + format: "pretty" | "json"; + }; + +let cachedConfig: ParsedConfig | undefined; +let cachedApp: string | undefined; + +declare const globalThis: { + __AGENTCRUMBS__?: string | AgentCrumbsConfig; + __AGENTCRUMBS_APP__?: string; +}; + +function namespaceToRegex(pattern: string): RegExp { + const escaped = pattern + .replace(/[.+?^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*?"); + return new RegExp(`^${escaped}$`); +} + +/** + * Configure agentcrumbs in the browser. + * + * @example + * configure("*") // enable all namespaces + * configure("myapp:*") // enable namespaces matching pattern + * configure({ ns: "*", app: "my-app", format: "pretty" }) + */ +export function configure(config: AgentCrumbsConfig | string): void { + cachedConfig = undefined; + cachedApp = undefined; + + if (typeof config === "string") { + (globalThis as Record).__AGENTCRUMBS__ = config; + } else { + (globalThis as Record).__AGENTCRUMBS__ = config; + } +} + +export function parseConfig(): ParsedConfig { + if (cachedConfig !== undefined) return cachedConfig; + + const raw = globalThis.__AGENTCRUMBS__; + if (!raw) { + cachedConfig = { enabled: false }; + return cachedConfig; + } + + let config: AgentCrumbsConfig; + + if (typeof raw === "object") { + config = raw; + } else { + // Shorthand: "1", "*", "true" → enable all + if (raw === "1" || raw === "*" || raw === "true") { + cachedConfig = { + enabled: true, + includes: [/^.*$/], + excludes: [], + port: DEFAULT_PORT, + format: "pretty", + }; + return cachedConfig; + } + + // Try parsing as JSON config object + try { + config = JSON.parse(raw) as AgentCrumbsConfig; + } catch { + config = { ns: raw }; + } + } + + const parts = config.ns.split(/[\s,]+/).filter(Boolean); + const includes: RegExp[] = []; + const excludes: RegExp[] = []; + + for (const part of parts) { + if (part.startsWith("-")) { + excludes.push(namespaceToRegex(part.slice(1))); + } else { + includes.push(namespaceToRegex(part)); + } + } + + if (includes.length === 0) { + cachedConfig = { enabled: false }; + return cachedConfig; + } + + cachedConfig = { + enabled: true, + app: config.app, + includes, + excludes, + port: config.port ?? DEFAULT_PORT, + format: config.format ?? "pretty", + }; + return cachedConfig; +} + +export function isNamespaceEnabled(namespace: string): boolean { + const config = parseConfig(); + if (!config.enabled) return false; + + const included = config.includes.some((re) => re.test(namespace)); + if (!included) return false; + + const excluded = config.excludes.some((re) => re.test(namespace)); + return !excluded; +} + +export function getCollectorUrl(): string { + const config = parseConfig(); + const port = config.enabled ? config.port : DEFAULT_PORT; + return `http://localhost:${port}/crumb`; +} + +export function getFormat(): "pretty" | "json" { + const config = parseConfig(); + if (!config.enabled) return "pretty"; + return config.format; +} + +/** + * Resolve the app name. Priority: + * 1. `app` field from configure() config + * 2. `globalThis.__AGENTCRUMBS_APP__` + * 3. Fallback: "browser" + */ +export function getApp(): string { + if (cachedApp !== undefined) return cachedApp; + + const config = parseConfig(); + if (config.enabled && config.app) { + cachedApp = config.app; + return cachedApp; + } + + const globalApp = globalThis.__AGENTCRUMBS_APP__; + if (globalApp) { + cachedApp = globalApp; + return cachedApp; + } + + cachedApp = "browser"; + return cachedApp; +} + +/** Reset cached config — useful for tests */ +export function resetConfig(): void { + cachedConfig = undefined; +} + +/** Reset cached app — useful for tests */ +export function resetApp(): void { + cachedApp = undefined; +} diff --git a/packages/agentcrumbs/src/env.ts b/packages/agentcrumbs/src/env.ts index b1eef22..acb4cf9 100644 --- a/packages/agentcrumbs/src/env.ts +++ b/packages/agentcrumbs/src/env.ts @@ -167,3 +167,10 @@ export function resetConfig(): void { export function resetApp(): void { cachedApp = undefined; } + +/** No-op in Node.js — use AGENTCRUMBS env var instead. */ +export function configure( + _config: AgentCrumbsConfig | string, +): void { + // In Node.js, use AGENTCRUMBS env var instead +} diff --git a/packages/agentcrumbs/src/index.ts b/packages/agentcrumbs/src/index.ts index b72749d..f537ebe 100644 --- a/packages/agentcrumbs/src/index.ts +++ b/packages/agentcrumbs/src/index.ts @@ -1,5 +1,6 @@ export { trail } from "./trail.js"; export { addSink, removeSink } from "./trail.js"; +export { configure } from "./env.js"; export { NOOP } from "./noop.js"; export { MemorySink } from "./sinks/memory.js"; export { ConsoleSink } from "./sinks/console.js"; diff --git a/packages/agentcrumbs/src/sinks/console-browser.mts b/packages/agentcrumbs/src/sinks/console-browser.mts new file mode 100644 index 0000000..1da0dcb --- /dev/null +++ b/packages/agentcrumbs/src/sinks/console-browser.mts @@ -0,0 +1,108 @@ +import type { Crumb, Sink } from "../types.js"; +import { getNamespaceColor } from "../colors.js"; + +// Map ANSI 256-color indices to CSS colors for DevTools +const COLOR_MAP: Record = { + 1: "#cc0000", + 2: "#4e9a06", + 3: "#c4a000", + 4: "#3465a4", + 5: "#75507b", + 6: "#06989a", + 9: "#ef2929", + 10: "#8ae234", + 11: "#fce94f", + 12: "#729fcf", + 13: "#ad7fa8", + 14: "#34e2e2", + 170: "#d75fd7", + 196: "#ff0000", + 202: "#ff5f00", + 208: "#ff8700", +}; + +function cssColor(colorIndex: number): string { + return COLOR_MAP[colorIndex] ?? "#999"; +} + +function formatDelta(dt: number): string { + if (dt < 1000) return `+${Math.round(dt)}ms`; + if (dt < 60000) return `+${(dt / 1000).toFixed(1)}s`; + return `+${(dt / 60000).toFixed(1)}m`; +} + +export class ConsoleSink implements Sink { + write(crumb: Crumb): void { + const color = cssColor(getNamespaceColor(crumb.ns)); + const dt = formatDelta(crumb.dt); + const depth = crumb.depth ?? 0; + const pad = " ".repeat(depth); + + const nsStyle = `color: ${color}; font-weight: bold`; + const dimStyle = "color: #999"; + const boldStyle = "font-weight: bold"; + + let label: string; + let styles: string[]; + + switch (crumb.type) { + case "scope:enter": + label = `%c${crumb.ns} %c${pad}%c[${crumb.msg}]%c -> enter %c${dt}`; + styles = [nsStyle, "", boldStyle, dimStyle, dimStyle]; + break; + case "scope:exit": + label = `%c${crumb.ns} %c${pad}%c[${crumb.msg}]%c <- exit %c${dt}`; + styles = [nsStyle, "", boldStyle, dimStyle, dimStyle]; + break; + case "scope:error": + label = `%c${crumb.ns} %c${pad}%c[${crumb.msg}]%c !! error %c${dt}`; + styles = [nsStyle, "", boldStyle, "color: red", dimStyle]; + break; + case "snapshot": + label = `%c${crumb.ns} %c${pad}%csnapshot:%c ${crumb.msg} %c${dt}`; + styles = [nsStyle, "", dimStyle, "", dimStyle]; + break; + case "assert": + label = `%c${crumb.ns} %c${pad}%cassert:%c ${crumb.msg} %c${dt}`; + styles = [nsStyle, "", dimStyle, "", dimStyle]; + break; + case "time": + label = `%c${crumb.ns} %c${pad}%ctime:%c ${crumb.msg} %c${dt}`; + styles = [nsStyle, "", dimStyle, "", dimStyle]; + break; + case "session:start": + label = `%c${crumb.ns} %c${pad}%csession start:%c ${crumb.msg} %c[${crumb.sid}] %c${dt}`; + styles = [nsStyle, "", boldStyle, "", dimStyle, dimStyle]; + break; + case "session:end": + label = `%c${crumb.ns} %c${pad}%csession end:%c ${crumb.msg} %c[${crumb.sid}] %c${dt}`; + styles = [nsStyle, "", boldStyle, "", dimStyle, dimStyle]; + break; + default: + label = `%c${crumb.ns} %c${pad}${crumb.msg} %c${dt}`; + styles = [nsStyle, "", dimStyle]; + } + + if (crumb.tags && crumb.tags.length > 0) { + label += ` %c[${crumb.tags.join(", ")}]`; + styles.push(dimStyle); + } + + const args: unknown[] = [label, ...styles]; + + // Pass data as an additional arg so DevTools renders it interactively + if (crumb.data !== undefined) { + args.push(crumb.data); + } + + // Use groupCollapsed for scope enter, groupEnd for scope exit + if (crumb.type === "scope:enter") { + console.groupCollapsed(...(args as [string, ...string[]])); + } else if (crumb.type === "scope:exit" || crumb.type === "scope:error") { + console.debug(...(args as [string, ...string[]])); + console.groupEnd(); + } else { + console.debug(...(args as [string, ...string[]])); + } + } +} diff --git a/packages/agentcrumbs/src/trail-browser.mts b/packages/agentcrumbs/src/trail-browser.mts new file mode 100644 index 0000000..79b202d --- /dev/null +++ b/packages/agentcrumbs/src/trail-browser.mts @@ -0,0 +1,296 @@ +import type { + Crumb, + CrumbOptions, + CrumbType, + Session, + Sink, + TrailFunction, +} from "./types.js"; +import { NOOP } from "./noop.js"; +import { isNamespaceEnabled, getCollectorUrl, getFormat, getApp } from "./env.js"; +import { getContext, runWithContext, type DebugContext } from "./context.js"; +import { ConsoleSink } from "./sinks/console.js"; +import { HttpSink } from "./sinks/socket.js"; + +const globalSinks: Sink[] = []; +let sinksInitialized = false; + +function ensureSinks(): void { + if (sinksInitialized) return; + sinksInitialized = true; + + const url = getCollectorUrl(); + globalSinks.push(new HttpSink(url)); + + const format = getFormat(); + if (format === "json") { + globalSinks.push({ + write(crumb: Crumb) { + console.debug(JSON.stringify(crumb)); + }, + }); + } else { + globalSinks.push(new ConsoleSink()); + } +} + +export function addSink(sink: Sink): void { + globalSinks.push(sink); + sinksInitialized = true; +} + +export function removeSink(sink: Sink): void { + const idx = globalSinks.indexOf(sink); + if (idx !== -1) globalSinks.splice(idx, 1); +} + +/** Reset sinks — for testing */ +export function resetSinks(): void { + globalSinks.length = 0; + sinksInitialized = false; +} + +function emit(crumb: Crumb): void { + ensureSinks(); + for (const sink of globalSinks) { + try { + sink.write(crumb); + } catch { + // Never let a sink error affect the application + } + } +} + +function createTrailFunction( + namespace: string, + parentCtx?: Record, +): TrailFunction { + let lastTime = performance.now(); + const timers = new Map(); + + function makeCrumb( + msg: string, + type: CrumbType, + data?: unknown, + options?: CrumbOptions, + overrides?: Partial, + ): Crumb { + const now = performance.now(); + const dt = now - lastTime; + lastTime = now; + + const asyncCtx = getContext(); + + const crumb: Crumb = { + app: getApp(), + ts: new Date().toISOString(), + ns: namespace, + msg, + type, + dt: Math.round(dt * 100) / 100, + pid: 0, + ...overrides, + }; + + if (data !== undefined) crumb.data = data; + + const mergedCtx = { + ...parentCtx, + ...asyncCtx?.contextData, + }; + if (Object.keys(mergedCtx).length > 0) crumb.ctx = mergedCtx; + + if (!crumb.traceId && asyncCtx?.traceId) crumb.traceId = asyncCtx.traceId; + if (!crumb.depth && asyncCtx?.depth) crumb.depth = asyncCtx.depth; + + const sid = asyncCtx?.sessionId; + if (sid) crumb.sid = sid; + + if (options?.tags && options.tags.length > 0) crumb.tags = options.tags; + + return crumb; + } + + const fn = function trailFn( + msg: string, + data?: unknown, + options?: CrumbOptions, + ): void { + emit(makeCrumb(msg, "crumb", data, options)); + }; + + fn.enabled = true; + fn.namespace = namespace; + + fn.child = (ctx: Record): TrailFunction => { + return createTrailFunction(namespace, { ...parentCtx, ...ctx }); + }; + + fn.scope = ( + name: string, + scopeFn: (ctx: { crumb: TrailFunction; traceId: string }) => T | Promise, + ): T | Promise => { + const traceId = crypto.randomUUID().slice(0, 8); + const asyncCtx = getContext(); + const depth = (asyncCtx?.depth ?? 0) + 1; + + const newCtx: DebugContext = { + namespace, + contextData: { ...parentCtx, ...asyncCtx?.contextData }, + traceId, + depth, + sessionId: asyncCtx?.sessionId, + }; + + emit(makeCrumb(name, "scope:enter", undefined, undefined, { traceId, depth })); + const startTime = performance.now(); + + const childTrail = createTrailFunction(namespace, newCtx.contextData); + + const finish = (error?: unknown) => { + const duration = Math.round((performance.now() - startTime) * 100) / 100; + if (error) { + const errorData = + error instanceof Error + ? { message: error.message, stack: error.stack, name: error.name } + : { value: error }; + emit( + makeCrumb(name, "scope:error", { ...errorData, duration }, undefined, { + traceId, + depth, + }), + ); + } else { + emit( + makeCrumb(name, "scope:exit", { duration }, undefined, { + traceId, + depth, + }), + ); + } + }; + + try { + const result = runWithContext(newCtx, () => + scopeFn({ crumb: childTrail, traceId }), + ); + + if (result instanceof Promise) { + return result.then( + (val) => { + finish(); + return val; + }, + (err) => { + finish(err); + throw err; + }, + ) as T | Promise; + } + + finish(); + return result; + } catch (err) { + finish(err); + throw err; + } + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fn.wrap = any>( + name: string, + wrappedFn: T, + ): T => { + return ((...args: unknown[]) => { + return fn.scope(name, () => wrappedFn(...args)); + }) as T; + }; + + fn.time = (label: string): void => { + timers.set(label, performance.now()); + }; + + fn.timeEnd = (label: string, data?: unknown): void => { + const start = timers.get(label); + if (start === undefined) return; + timers.delete(label); + const duration = Math.round((performance.now() - start) * 100) / 100; + emit(makeCrumb(label, "time", { ...((data as object) ?? {}), duration })); + }; + + fn.snapshot = (label: string, obj: unknown): void => { + let cloned: unknown; + try { + cloned = structuredClone(obj); + } catch { + cloned = obj; + } + emit(makeCrumb(label, "snapshot", cloned)); + }; + + fn.assert = (condition: unknown, msg: string): void => { + if (!condition) { + emit(makeCrumb(msg, "assert", { passed: false })); + } + }; + + fn.session = (( + name: string, + sessionFn?: (session: Session) => unknown, + ): unknown => { + const id = crypto.randomUUID().slice(0, 8); + const asyncCtx = getContext(); + + const sessionCtx: DebugContext = { + namespace, + contextData: { ...parentCtx, ...asyncCtx?.contextData }, + traceId: asyncCtx?.traceId ?? "", + depth: asyncCtx?.depth ?? 0, + sessionId: id, + }; + + emit(makeCrumb(name, "session:start", undefined, undefined, { sid: id })); + + const session: Session = { + id, + name, + crumb: (msg: string, data?: unknown, options?: CrumbOptions) => { + runWithContext(sessionCtx, () => { + emit(makeCrumb(msg, "crumb", data, options, { sid: id })); + }); + }, + end: () => { + emit(makeCrumb(name, "session:end", undefined, undefined, { sid: id })); + }, + }; + + if (typeof sessionFn === "function") { + const result = runWithContext(sessionCtx, () => sessionFn(session)); + if (result instanceof Promise) { + return result.then( + (val) => { + session.end(); + return val; + }, + (err) => { + session.end(); + throw err; + }, + ); + } + session.end(); + return result; + } + + return session; + }) as TrailFunction["session"]; + + return fn as TrailFunction; +} + +export function trail(namespace: string): TrailFunction { + if (!isNamespaceEnabled(namespace)) { + return NOOP; + } + return createTrailFunction(namespace); +}