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);
+}