@heystack/otel 0.9.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -6
- package/dist/core.d.ts +34 -0
- package/dist/core.js +26 -0
- package/dist/next.js +11 -1
- package/dist/node.d.ts +12 -0
- package/dist/node.js +23 -1
- package/dist/web.d.ts +233 -1
- package/dist/web.js +561 -14
- package/dist/workers.d.ts +12 -0
- package/dist/workers.js +9 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,9 +22,38 @@ HEYSTACK_API_KEY=sk_live_…
|
|
|
22
22
|
| Next.js — **any** deploy target (Vercel/Node **and** Cloudflare/OpenNext) | `@heystack/otel/next` | `registerHeystack` in `instrumentation.ts`. Auto-detects Node vs Cloudflare workerd and picks the right exporter. No-op on Edge. |
|
|
23
23
|
| Standalone Cloudflare Workers (hand-written `export default { fetch }`) | `@heystack/otel/workers` | `instrument()` wraps your handler. Fetch-based exporter, flushes via `ctx.waitUntil`. |
|
|
24
24
|
| Node / Express / Fastify / NestJS (long-running server) | `@heystack/otel/node` | `initHeystack`: auto-instrumentations + graceful shutdown. |
|
|
25
|
-
| Browser (SPA / any web frontend) | `@heystack/otel/web` | `instrumentWeb`: session replay + W3C `traceparent`
|
|
25
|
+
| Browser (SPA / any web frontend) | `@heystack/otel/web` | `instrumentWeb`: session replay + opt-in browser distributed tracing (`tracing: true`) that emits CLIENT spans + propagates W3C `traceparent` (browser→API shows as one trace). No-op on the server (SSR-safe). |
|
|
26
26
|
| Anywhere (pure helpers) | `@heystack/otel` | `buildExporterConfig`, types. No Node SDK loaded. |
|
|
27
27
|
|
|
28
|
+
## Release & commit attribution (`version` / `build`)
|
|
29
|
+
|
|
30
|
+
Two optional options tag every span with **which release and commit it came from**, so the console can power **release health**, **suspect release**, and **suspect commit** views (spot the exact version/commit that introduced a regression). They work on **every runtime entry** (`/node`, `/next`, `/workers`).
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
initHeystack({
|
|
34
|
+
apiKey: process.env.HEYSTACK_API_KEY,
|
|
35
|
+
service: "my-app",
|
|
36
|
+
version: process.env.APP_VERSION, // → service.version (e.g. "1.4.2" or a git tag)
|
|
37
|
+
build: process.env.GIT_SHA, // → service.build (the commit SHA)
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
| Option | Resource attribute | Meaning |
|
|
42
|
+
| --- | --- | --- |
|
|
43
|
+
| `version` | `service.version` | Release identifier — a semver (`1.4.2`), a git tag, or any string that changes per release. Groups telemetry by release for **release health** + **suspect release**. |
|
|
44
|
+
| `build` | `service.build` | The commit SHA of this deploy. Powers **suspect commit** — when you configure a repository URL for the app in the console, the SHA deep-links to the exact commit. |
|
|
45
|
+
|
|
46
|
+
Both are optional and only emitted when set — an empty value is skipped (the console's release queries filter these out). Set them from your build/deploy environment, e.g. `build: process.env.GIT_SHA` (`GIT_SHA=$(git rev-parse HEAD)` in CI).
|
|
47
|
+
|
|
48
|
+
On **`/node`** they also fall back to environment variables when the option is omitted: `HEYSTACK_SERVICE_VERSION` (or `OTEL_SERVICE_VERSION`) for the release and `HEYSTACK_SERVICE_BUILD` for the commit — so a Node deploy can attribute a release without touching code:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
HEYSTACK_SERVICE_VERSION=1.4.2
|
|
52
|
+
HEYSTACK_SERVICE_BUILD=$(git rev-parse HEAD)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
On **`/next`** pass them to `registerHeystack({ service, version, build })` — they're threaded to whichever runtime the app runs on (Node or workerd). On **`/workers`** pass them in the `instrument()` / `initHeystackWorkers()` config.
|
|
56
|
+
|
|
28
57
|
## Node / Express / etc.
|
|
29
58
|
|
|
30
59
|
At the very top of your app's entry file:
|
|
@@ -32,10 +61,15 @@ At the very top of your app's entry file:
|
|
|
32
61
|
```ts
|
|
33
62
|
import { initHeystack } from "@heystack/otel/node";
|
|
34
63
|
|
|
35
|
-
initHeystack({
|
|
64
|
+
initHeystack({
|
|
65
|
+
apiKey: process.env.HEYSTACK_API_KEY,
|
|
66
|
+
service: "my-app",
|
|
67
|
+
version: process.env.APP_VERSION, // optional: release health + suspect release/commit
|
|
68
|
+
build: process.env.GIT_SHA, // optional: commit SHA (see "Release & commit attribution")
|
|
69
|
+
});
|
|
36
70
|
```
|
|
37
71
|
|
|
38
|
-
This enables auto-instrumentations (HTTP, Express, etc.) so you get spans without manual wiring.
|
|
72
|
+
This enables auto-instrumentations (HTTP, Express, etc.) so you get spans without manual wiring. `version`/`build` are optional (see [Release & commit attribution](#release--commit-attribution-version--build)).
|
|
39
73
|
|
|
40
74
|
### Slimming down auto-instrumentations (cost)
|
|
41
75
|
|
|
@@ -120,6 +154,8 @@ Set the key as a secret: `wrangler secret put HEYSTACK_API_KEY`.
|
|
|
120
154
|
| `sampling` | `{ rate?: number } \| { remote: true }` | Head-sampling configuration. `{ rate }`: keep a deterministic fraction of fresh root traces (0–1; default `1` = keep all). `{ remote: true }`: fetch the rate from the Heystack config endpoint instead — lets you change it centrally without redeploying. Cold isolates keep all traffic until the first config fetch resolves; fails open if the config can't be reached. Parent-respecting in both modes: a request arriving with a sampled `traceparent` is always recorded. See [Head sampling](#head-sampling) below. |
|
|
121
155
|
| `ai` | `{ captureContent?: boolean; redact?: (text: string) => string; maxContentChars?: number }` | LLM/gen_ai capture for outbound calls to known providers. See [AI / LLM observability](#ai--llm-observability) below. |
|
|
122
156
|
| `waitUntil` | `(p: Promise<unknown>) => void` | Override the isolate keep-alive hook; defaults to the auto-detected `ctx.waitUntil`. |
|
|
157
|
+
| `version` | `string?` | Release identifier → `service.version` resource attribute. Powers release health + suspect release. See [Release & commit attribution](#release--commit-attribution-version--build). |
|
|
158
|
+
| `build` | `string?` | Commit SHA → `service.build` resource attribute. Powers suspect commit (deep-links to the commit when a repo URL is set in the console). |
|
|
123
159
|
| `endpoint` | `string?` | Override the ingest endpoint (advanced). |
|
|
124
160
|
|
|
125
161
|
### Head sampling
|
|
@@ -304,13 +340,18 @@ The default export still needs to be wrapped with `instrument()` (or `initHeysta
|
|
|
304
340
|
|
|
305
341
|
## `@heystack/otel/web` (browser / session replay)
|
|
306
342
|
|
|
307
|
-
For a browser frontend (any SPA / web app), `instrumentWeb` records **session replay** and injects a W3C `traceparent` header on outgoing `fetch` calls, so replays correlate with the backend traces they triggered. It is a **no-op on the server** (SSR-safe), so it's safe to call from code that also runs during server rendering.
|
|
343
|
+
For a browser frontend (any SPA / web app), `instrumentWeb` records **session replay**, captures **uncaught errors + `console.error`** as logs, and injects a W3C `traceparent` header on outgoing `fetch` calls, so replays/errors correlate with the backend traces they triggered. It is a **no-op on the server** (SSR-safe), so it's safe to call from code that also runs during server rendering.
|
|
344
|
+
|
|
345
|
+
The rrweb recorder **ships inside this package** — there is nothing else to install. Uploads go cross-origin to the Heystack ingest endpoint and **work out of the box** (no CORS configuration on your side).
|
|
308
346
|
|
|
309
347
|
```ts
|
|
310
348
|
import { instrumentWeb } from "@heystack/otel/web";
|
|
311
349
|
|
|
312
350
|
const stop = await instrumentWeb({
|
|
313
|
-
|
|
351
|
+
// A BROWSER-exposed ingest key — it ships to the client, like an analytics
|
|
352
|
+
// write key. Use a public env var (below), ideally a dedicated key you can
|
|
353
|
+
// rotate independently of your server-side key. NOT your server secret.
|
|
354
|
+
apiKey: import.meta.env.VITE_HEYSTACK_API_KEY, // Vite; Next.js: process.env.NEXT_PUBLIC_HEYSTACK_API_KEY
|
|
314
355
|
service: "my-web-app",
|
|
315
356
|
});
|
|
316
357
|
|
|
@@ -320,17 +361,88 @@ stop();
|
|
|
320
361
|
|
|
321
362
|
`instrumentWeb` returns a `stop()` function that ends the recording session.
|
|
322
363
|
|
|
364
|
+
**Where to call it.** It must run in the **browser**. In a Vite/CRA SPA, call it once in your client entry (`main.tsx`). In **Next.js (App Router)**, wrap it in a small `"use client"` component that calls it from a `useEffect` and mount that once in your root layout (server components can't call it). Recording is **server-gated**: nothing is captured until you enable replay for the app in the console, so it's safe to ship this before flipping the switch.
|
|
365
|
+
|
|
323
366
|
### Options
|
|
324
367
|
|
|
325
368
|
| Option | Type | Notes |
|
|
326
369
|
| --- | --- | --- |
|
|
327
|
-
| `apiKey` | `string` | **Required.**
|
|
370
|
+
| `apiKey` | `string` | **Required.** A Heystack ingest key, **exposed to the browser** (public env var). Prefer a dedicated key you can rotate — not your server-side secret. |
|
|
328
371
|
| `service` | `string` | **Required.** The OTel service name (matches the app's service in the console). |
|
|
329
372
|
| `userId` | `string?` | Optional app-supplied identifier stamped on the session. |
|
|
330
373
|
| `endpoint` | `string?` | Optional ingest endpoint override (defaults to the Heystack ingest endpoint). |
|
|
331
374
|
| `sampleRate` | `number?` | Optional **local** override for the recording sample rate (0–1). By default sampling is controlled from the console. |
|
|
332
375
|
| `flushIntervalMs` | `number?` | How often buffered events are flushed (default 5000ms). |
|
|
333
376
|
| `flushEveryEvents` | `number?` | Max buffered events before an early flush (default 200). |
|
|
377
|
+
| `tracing` | `boolean?` | Opt in to **browser distributed tracing** (default off). Emits a real CLIENT span per outbound `fetch` and propagates W3C trace context, so browser→backend calls show as one connected trace + a service-map edge. Independent of replay. |
|
|
378
|
+
| `traceSampleRate` | `number?` | Head sample rate for browser tracing (0–1, default 1 when `tracing` is on). Lower it to cap span volume/cost on busy apps. |
|
|
379
|
+
| `errors` | `boolean?` | Capture uncaught browser errors (`window.onerror` + `unhandledrejection`) as logs. **On by default** — set `false` to disable. Independent of replay/tracing. |
|
|
380
|
+
| `captureConsole` | `'error' \| 'warn' \| false` | Capture `console` output as logs. `'error'` (default) captures `console.error`; `'warn'` captures `console.warn` **and** `console.error`; `false` disables it. Rate-capped + recursion-guarded. |
|
|
381
|
+
| `version` | `string?` | Release identifier → the `service.version` resource attribute on exported browser logs (release attribution / suspect release). |
|
|
382
|
+
| `build` | `string?` | Commit SHA → the `service.build` resource attribute on exported browser logs. |
|
|
383
|
+
|
|
384
|
+
### Browser distributed tracing
|
|
385
|
+
|
|
386
|
+
By default `/web` only records session replay. Set `tracing: true` to also trace the browser: each outbound `fetch` becomes a CLIENT span, and the injected `traceparent` makes the downstream service's SERVER span its child — so a browser→API call renders as **one connected trace** and a **service-map edge** (`web → api`). This is separate from replay (it works even with replay off).
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
await instrumentWeb({
|
|
390
|
+
apiKey: import.meta.env.VITE_HEYSTACK_API_KEY,
|
|
391
|
+
service: "my-web-app",
|
|
392
|
+
tracing: true,
|
|
393
|
+
traceSampleRate: 0.25, // sample 25% of requests — tune for cost
|
|
394
|
+
});
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
It's **cost-aware and safe by design**: off unless you opt in; head-sampled (an unsampled request still propagates `traceparent` with the sampled flag cleared, so the backend makes the same keep/drop decision — no orphaned server spans); and the exporter posts through the *original* `fetch`, never tracing its own upload (no self-export loop). Spans post to `/v1/traces` cross-origin with no CORS setup on your side.
|
|
398
|
+
|
|
399
|
+
### Browser error & console collection
|
|
400
|
+
|
|
401
|
+
`instrumentWeb` captures **uncaught browser errors** out of the box — no extra setup. Any `window.onerror` / unhandled promise rejection becomes an OTLP **log** (`event.name=browser.error`, `ERROR` severity) carrying the OTel `exception.type` / `exception.message` / `exception.stacktrace` semconv attributes, the page `url.full`, the `session_id` (so it correlates to the session replay), and — when `tracing` is on — the active `trace_id` / `span_id`. `console.error` is captured too by default.
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
await instrumentWeb({
|
|
405
|
+
apiKey: import.meta.env.VITE_HEYSTACK_API_KEY,
|
|
406
|
+
service: "my-web-app",
|
|
407
|
+
version: "1.4.2", // → service.version (release attribution)
|
|
408
|
+
build: "abc1234", // → service.build (commit)
|
|
409
|
+
captureConsole: "warn", // capture console.warn + console.error (default: 'error')
|
|
410
|
+
// errors: false, // opt out of error capture entirely
|
|
411
|
+
});
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
It's the **same cost-safe design** as tracing/replay: logs POST to `/v1/logs` through the *original* `fetch` (captured before any patching) so the exporter never traces its own upload — no self-export loop. Console capture is **recursion-guarded** (anything logged on the export path is never re-captured) and **rate-capped** (max ~60 records/minute; the overflow is dropped and counted, with one summary log emitted when the cap lifts) so a runaway `console.error` in a render loop can't flood ingest. Errors show up in the console **Logs** tab and correlate to their session replay and trace.
|
|
415
|
+
|
|
416
|
+
### In-app bug reports (`reportBug`)
|
|
417
|
+
|
|
418
|
+
Let users report a bug from inside your app. `reportBug` is a headless API — **no widget, you own the UX** — that files a structured report and auto-attaches the context a triager needs: the current URL, user agent, replay `session_id`, active `trace_id`, `release` / `build`, and the **last few captured browser errors** (so you see what was going wrong on the page right before the report). The report also appears on the Logs timeline (`event.name=user.bug_report`).
|
|
419
|
+
|
|
420
|
+
Call it any time after `instrumentWeb()` has run (it throws if the SDK isn't initialised yet, or if `message` is empty). Network failures are swallowed — a failed report never breaks your app.
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
import { instrumentWeb, reportBug } from "@heystack/otel/web";
|
|
424
|
+
|
|
425
|
+
await instrumentWeb({ apiKey: import.meta.env.VITE_HEYSTACK_API_KEY, service: "my-web-app" });
|
|
426
|
+
|
|
427
|
+
// A minimal report button (wire this to whatever UI you like):
|
|
428
|
+
const btn = document.querySelector("#report-bug")!;
|
|
429
|
+
btn.addEventListener("click", async () => {
|
|
430
|
+
const message = prompt("What went wrong?");
|
|
431
|
+
if (!message) return;
|
|
432
|
+
await reportBug({
|
|
433
|
+
message,
|
|
434
|
+
email: currentUser?.email, // optional — so the team can follow up
|
|
435
|
+
context: { plan: "pro", screen: "checkout" }, // optional app metadata (string→string)
|
|
436
|
+
});
|
|
437
|
+
alert("Thanks — your report was sent.");
|
|
438
|
+
});
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
Reports land in the console under the app's **Bugs** tab, already linked to the session replay, the trace, and the recent errors — one click from report to root cause. The POST goes to `/v1/bug-reports` via the same *original* `fetch` used by the other exporters (no self-export loop).
|
|
442
|
+
|
|
443
|
+
### Session-replay keyframes (seekable playback)
|
|
444
|
+
|
|
445
|
+
The recorder checkpoints a fresh full snapshot roughly every **60 seconds** and starts a new chunk at each one (a "keyframe"). The console player can therefore begin playback from the nearest keyframe at or before a target moment — e.g. jumping straight to where an error happened — instead of downloading the whole session first. No configuration; it's automatic.
|
|
334
446
|
|
|
335
447
|
### Sampling & masking come from the console
|
|
336
448
|
|
|
@@ -364,6 +476,8 @@ As belt-and-suspenders the exporter also drops any span whose HTTP target points
|
|
|
364
476
|
|
|
365
477
|
## Migration / versioning
|
|
366
478
|
|
|
479
|
+
- **`0.11.0`** — **`/web`: browser error + console collection, in-app bug reports, and seekable replay keyframes.** `instrumentWeb` now captures uncaught errors (`window.onerror` + `unhandledrejection`) and `console.error` as OTLP logs by default (`event.name=browser.error` / `browser.console`, with `exception.*` semconv, `url.full`, `session_id`, and the active trace/span ids when `tracing` is on). New options: `errors?: boolean` (default `true`), `captureConsole?: 'error' | 'warn' | false` (default `'error'`). New **`reportBug({ message, email?, context? })`** API — a headless in-app bug reporter that POSTs to `/v1/bug-reports` and auto-attaches the URL, user agent, `session_id`, active `trace_id`, `release`/`build`, and the last ≤20 captured browser errors (also emitted as a `user.bug_report` log); reports triage in the console **Bugs** tab. The `version` / `build` options are now stamped as `service.version` / `service.build` on exported browser logs (release attribution). Console capture is recursion-guarded + rate-capped (no self-export loop, no flood). The recorder also checkpoints a keyframe (~60s) so the console player seeks from the nearest snapshot instead of loading the whole session. No breaking changes; all new options are optional.
|
|
480
|
+
- **`0.10.0`** — **release / commit attribution (`version` + `build`) on every runtime entry.** New optional options: `version` → the `service.version` resource attribute (a release identifier such as `1.4.2` or a git tag), `build` → the `service.build` resource attribute (the commit SHA). They power **release health**, **suspect release**, and **suspect commit** in the console — attributing a regression to the version/commit that introduced it (suspect-commit deep-links to the commit when a repo URL is configured for the app). Wired on `/node`, `/next` (threaded to whichever runtime runs), and `/workers`; accepted on `/web` for API symmetry. `/node` also reads `HEYSTACK_SERVICE_VERSION` / `OTEL_SERVICE_VERSION` and `HEYSTACK_SERVICE_BUILD` as env fallbacks. Both options are optional and only emitted when non-empty. No breaking changes. See [Release & commit attribution](#release--commit-attribution-version--build).
|
|
367
481
|
- **`0.9.2`** — **`/workers`: `instrument()` no longer breaks WebSocket / Durable Object upgrades.** The fetch wrapper rebuilt every response with `new Response(...)`, which **drops the `webSocket` property** on a `101 Switching Protocols` upgrade — so every WebSocket connection on an instrumented Worker failed *whenever tracing was active* (an API key set), while passing in dev/staging where `instrument()` is a passthrough. `instrument()` now detects an upgrade (`response.webSocket` present, or `status === 101`) and returns the **original response untouched** (span closed immediately; no `traceparent` header injected — headers on a 101 aren't delivered anyway). Fixes failed `wss://` connections behind `routeAgentRequest` / any `new WebSocketPair()` handler. No API change; upgrade and redeploy.
|
|
368
482
|
- **`0.9.1`** — **`/workers`: binding spans no longer orphan without `nodejs_compat`.** On a workerd runtime without `nodejs_compat` (no `globalThis.AsyncLocalStorage`), the synchronous fallback context manager cannot carry the active span across an `await` — so a D1/KV/R2/Vectorize/AI/Queue/Service span created *after* an `await` in your handler was emitted as the root of its own single-span trace instead of a child of the request. `instrument()` now captures the request's SERVER context per-request and uses it as an explicit parent fallback when no span is active, so binding operations are always children of the request trace. `nodejs_compat` is still recommended for cross-`await` parenting of outbound-fetch CLIENT spans and manual `withSpan` spans. No API change; upgrade and redeploy.
|
|
369
483
|
- **`0.9.0`** — **`/workers`: automatic LLM gen_ai enrichment for outbound API calls.** Outbound `fetch` calls to known LLM providers (OpenAI, Anthropic, Cloudflare AI Gateway, Google) automatically gain `gen_ai.*` OTel semantic-convention attributes on the CLIENT span — model, token counts, finish reason, response ID — with no extra code. New optional `WorkersConfig.ai` option: `captureContent: true` also captures prompt/completion text (off by default; **strongly recommended for AI-app RCA**), with `redact` for scrubbing and `maxContentChars` for length capping. The original request/response bodies are never consumed (request read only when already a string; response via `response.clone()`). Streaming responses skip response enrichment. No breaking changes.
|
package/dist/core.d.ts
CHANGED
|
@@ -6,7 +6,41 @@ export interface HeystackOptions {
|
|
|
6
6
|
service: string;
|
|
7
7
|
/** Override the ingest endpoint (defaults to Heystack production). */
|
|
8
8
|
endpoint?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Release identifier for this deploy — maps to the `service.version` resource
|
|
11
|
+
* attribute (a semver like `1.4.2`, a git tag, or any string that changes per
|
|
12
|
+
* release). Powers **release health** and the **suspect-release** view in the
|
|
13
|
+
* console: it groups telemetry by release so a regression can be pinned to the
|
|
14
|
+
* version that introduced it. Recommended for every deploy.
|
|
15
|
+
*/
|
|
16
|
+
version?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Commit SHA for this deploy — maps to the `service.build` resource attribute.
|
|
19
|
+
* Powers **suspect-commit** attribution: when a repository URL is configured
|
|
20
|
+
* for the app in the console, the SHA becomes a deep link to the exact commit.
|
|
21
|
+
* Use the full or short git SHA of the build.
|
|
22
|
+
*/
|
|
23
|
+
build?: string;
|
|
9
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Resource-attribute key for the commit/build identifier. `service.version` has a
|
|
27
|
+
* standard OTel semantic-convention constant (`ATTR_SERVICE_VERSION`); there is
|
|
28
|
+
* no standard key for the build SHA, so Heystack uses the `service.build` literal.
|
|
29
|
+
*/
|
|
30
|
+
export declare const ATTR_SERVICE_BUILD = "service.build";
|
|
31
|
+
/**
|
|
32
|
+
* Pure, runtime-agnostic: map the optional release-attribution options to OTel
|
|
33
|
+
* resource attributes — `version` → `service.version`, `build` → `service.build`.
|
|
34
|
+
*
|
|
35
|
+
* Only non-empty values are included: the console's release queries filter
|
|
36
|
+
* `!= ''`, so emitting an empty attribute is pure noise. Depends on nothing but
|
|
37
|
+
* the pure `@opentelemetry/semantic-conventions` constant, so it is safe for
|
|
38
|
+
* every entry including the WinterCG-only `/workers` path.
|
|
39
|
+
*/
|
|
40
|
+
export declare function releaseResourceAttributes(o: {
|
|
41
|
+
version?: string;
|
|
42
|
+
build?: string;
|
|
43
|
+
}): Record<string, string>;
|
|
10
44
|
export interface ExporterConfig {
|
|
11
45
|
url: string;
|
|
12
46
|
headers: {
|
package/dist/core.js
CHANGED
|
@@ -1,4 +1,30 @@
|
|
|
1
|
+
import { ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
|
|
1
2
|
export const DEFAULT_ENDPOINT = "https://ingest.heystack.dev";
|
|
3
|
+
/**
|
|
4
|
+
* Resource-attribute key for the commit/build identifier. `service.version` has a
|
|
5
|
+
* standard OTel semantic-convention constant (`ATTR_SERVICE_VERSION`); there is
|
|
6
|
+
* no standard key for the build SHA, so Heystack uses the `service.build` literal.
|
|
7
|
+
*/
|
|
8
|
+
export const ATTR_SERVICE_BUILD = "service.build";
|
|
9
|
+
/**
|
|
10
|
+
* Pure, runtime-agnostic: map the optional release-attribution options to OTel
|
|
11
|
+
* resource attributes — `version` → `service.version`, `build` → `service.build`.
|
|
12
|
+
*
|
|
13
|
+
* Only non-empty values are included: the console's release queries filter
|
|
14
|
+
* `!= ''`, so emitting an empty attribute is pure noise. Depends on nothing but
|
|
15
|
+
* the pure `@opentelemetry/semantic-conventions` constant, so it is safe for
|
|
16
|
+
* every entry including the WinterCG-only `/workers` path.
|
|
17
|
+
*/
|
|
18
|
+
export function releaseResourceAttributes(o) {
|
|
19
|
+
const attrs = {};
|
|
20
|
+
const version = o.version?.trim();
|
|
21
|
+
const build = o.build?.trim();
|
|
22
|
+
if (version)
|
|
23
|
+
attrs[ATTR_SERVICE_VERSION] = version;
|
|
24
|
+
if (build)
|
|
25
|
+
attrs[ATTR_SERVICE_BUILD] = build;
|
|
26
|
+
return attrs;
|
|
27
|
+
}
|
|
2
28
|
/** Pure: derive the OTLP/HTTP traces URL + auth header. Works in any runtime. */
|
|
3
29
|
export function buildExporterConfig(o) {
|
|
4
30
|
const base = (o.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, "");
|
package/dist/next.js
CHANGED
|
@@ -56,7 +56,14 @@ export async function registerHeystack(o) {
|
|
|
56
56
|
// "./workers" → "./dist/workers.js" and "./node" → "./dist/node.js".
|
|
57
57
|
const { initHeystackWorkers } = await import(
|
|
58
58
|
/* @vite-ignore */ /* webpackIgnore: true */ "@heystack/otel/workers");
|
|
59
|
-
initHeystackWorkers({
|
|
59
|
+
initHeystackWorkers({
|
|
60
|
+
apiKey,
|
|
61
|
+
service: o.service,
|
|
62
|
+
endpoint: o.endpoint,
|
|
63
|
+
// Release/commit attribution — carried into the workerd resource.
|
|
64
|
+
version: o.version,
|
|
65
|
+
build: o.build,
|
|
66
|
+
});
|
|
60
67
|
}
|
|
61
68
|
else {
|
|
62
69
|
const { initHeystack } = await import(
|
|
@@ -65,6 +72,9 @@ export async function registerHeystack(o) {
|
|
|
65
72
|
apiKey,
|
|
66
73
|
service: o.service,
|
|
67
74
|
endpoint: o.endpoint,
|
|
75
|
+
// Release/commit attribution — carried into the NodeSDK resource.
|
|
76
|
+
version: o.version,
|
|
77
|
+
build: o.build,
|
|
68
78
|
debug: o.debug,
|
|
69
79
|
});
|
|
70
80
|
}
|
package/dist/node.d.ts
CHANGED
|
@@ -52,6 +52,18 @@ export declare class SelfSpanFilteringExporter implements SpanExporter {
|
|
|
52
52
|
shutdown(): Promise<void>;
|
|
53
53
|
forceFlush(): Promise<void>;
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the release-attribution resource attributes for the Node entry.
|
|
57
|
+
*
|
|
58
|
+
* Prefers the explicit `version`/`build` options, then falls back to environment
|
|
59
|
+
* variables — `HEYSTACK_SERVICE_VERSION` (or `OTEL_SERVICE_VERSION`) for the
|
|
60
|
+
* release, `HEYSTACK_SERVICE_BUILD` for the commit SHA — so a deploy can stamp
|
|
61
|
+
* the release/commit without touching code (e.g. `HEYSTACK_SERVICE_BUILD=$(git
|
|
62
|
+
* rev-parse HEAD)`). Node-only: `process` is read behind a `typeof` guard so the
|
|
63
|
+
* function never references an undefined global. Returns only the keys that
|
|
64
|
+
* resolved to a non-empty value (empty attributes are noise the console filters).
|
|
65
|
+
*/
|
|
66
|
+
export declare function resolveNodeReleaseAttributes(o: NodeOptions): Record<string, string>;
|
|
55
67
|
/** Initialise Heystack tracing on a Node runtime. Call once, as early as possible. Returns the started SDK. */
|
|
56
68
|
export declare function initHeystack(o: NodeOptions): NodeSDK;
|
|
57
69
|
/** Flush + shutdown the SDK on SIGTERM/SIGINT so short-lived processes don't lose the last batch. Registers handlers at most once. */
|
package/dist/node.js
CHANGED
|
@@ -3,7 +3,8 @@ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
|
3
3
|
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
|
|
4
4
|
import { diag, DiagConsoleLogger, DiagLogLevel } from "@opentelemetry/api";
|
|
5
5
|
import { ExportResultCode } from "@opentelemetry/core";
|
|
6
|
-
import {
|
|
6
|
+
import { Resource } from "@opentelemetry/resources";
|
|
7
|
+
import { buildExporterConfig, releaseResourceAttributes, } from "./core.js";
|
|
7
8
|
import { isSelfSpanAttrs, safeHostname } from "./self-span.js";
|
|
8
9
|
/**
|
|
9
10
|
* Wraps a span exporter and drops any span that targets the Heystack ingest
|
|
@@ -88,6 +89,23 @@ let _signalHandlersRegistered = false;
|
|
|
88
89
|
* handlers).
|
|
89
90
|
*/
|
|
90
91
|
let _sdk = null;
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the release-attribution resource attributes for the Node entry.
|
|
94
|
+
*
|
|
95
|
+
* Prefers the explicit `version`/`build` options, then falls back to environment
|
|
96
|
+
* variables — `HEYSTACK_SERVICE_VERSION` (or `OTEL_SERVICE_VERSION`) for the
|
|
97
|
+
* release, `HEYSTACK_SERVICE_BUILD` for the commit SHA — so a deploy can stamp
|
|
98
|
+
* the release/commit without touching code (e.g. `HEYSTACK_SERVICE_BUILD=$(git
|
|
99
|
+
* rev-parse HEAD)`). Node-only: `process` is read behind a `typeof` guard so the
|
|
100
|
+
* function never references an undefined global. Returns only the keys that
|
|
101
|
+
* resolved to a non-empty value (empty attributes are noise the console filters).
|
|
102
|
+
*/
|
|
103
|
+
export function resolveNodeReleaseAttributes(o) {
|
|
104
|
+
const env = typeof process !== "undefined" ? process.env : undefined;
|
|
105
|
+
const version = o.version ?? env?.HEYSTACK_SERVICE_VERSION ?? env?.OTEL_SERVICE_VERSION;
|
|
106
|
+
const build = o.build ?? env?.HEYSTACK_SERVICE_BUILD;
|
|
107
|
+
return releaseResourceAttributes({ version, build });
|
|
108
|
+
}
|
|
91
109
|
/** Initialise Heystack tracing on a Node runtime. Call once, as early as possible. Returns the started SDK. */
|
|
92
110
|
export function initHeystack(o) {
|
|
93
111
|
// Idempotent: a second call returns the cached SDK rather than starting a new
|
|
@@ -111,6 +129,10 @@ export function initHeystack(o) {
|
|
|
111
129
|
const traceExporter = new SelfSpanFilteringExporter(new OTLPTraceExporter({ url: cfg.url, headers: cfg.headers }), ingestHost);
|
|
112
130
|
const sdk = new NodeSDK({
|
|
113
131
|
serviceName: o.service,
|
|
132
|
+
// Release/commit attribution. NodeSDK merges `serviceName` on top of this
|
|
133
|
+
// resource, so the final resource carries service.name + (when provided)
|
|
134
|
+
// service.version + service.build. Empty when neither option nor env is set.
|
|
135
|
+
resource: new Resource(resolveNodeReleaseAttributes(o)),
|
|
114
136
|
traceExporter,
|
|
115
137
|
instrumentations,
|
|
116
138
|
});
|
package/dist/web.d.ts
CHANGED
|
@@ -58,6 +58,20 @@ export interface InstrumentWebOptions {
|
|
|
58
58
|
apiKey: string;
|
|
59
59
|
service: string;
|
|
60
60
|
endpoint?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Release identifier for this build — the same `service.version` concept as the
|
|
63
|
+
* server entries (see `HeystackOptions.version`). Stamped as `service.version` on
|
|
64
|
+
* the resource of every browser error/console log exported here, so a browser
|
|
65
|
+
* regression is attributed to the release that introduced it (release health /
|
|
66
|
+
* suspect release in the console).
|
|
67
|
+
*/
|
|
68
|
+
version?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Commit SHA for this build — the same `service.build` concept as the server
|
|
71
|
+
* entries (see `HeystackOptions.build`). Stamped as `service.build` on the
|
|
72
|
+
* resource of exported browser logs for commit-level attribution.
|
|
73
|
+
*/
|
|
74
|
+
build?: string;
|
|
61
75
|
/** Optional app-supplied user identifier stamped on the session. */
|
|
62
76
|
userId?: string;
|
|
63
77
|
/** Local overrides; by default sampling + masking come from server config. */
|
|
@@ -65,11 +79,35 @@ export interface InstrumentWebOptions {
|
|
|
65
79
|
flushIntervalMs?: number;
|
|
66
80
|
/** Max buffered events before an early flush. */
|
|
67
81
|
flushEveryEvents?: number;
|
|
82
|
+
/**
|
|
83
|
+
* Opt in to browser distributed tracing: emit a real CLIENT span per outbound
|
|
84
|
+
* fetch and propagate W3C trace context, so browser→backend calls show as one
|
|
85
|
+
* connected trace (and a service-map edge). Off by default — it adds span
|
|
86
|
+
* volume (backend cost). Independent of session replay.
|
|
87
|
+
*/
|
|
88
|
+
tracing?: boolean;
|
|
89
|
+
/** Head sample rate for browser tracing (0–1, default 1 when `tracing` is on).
|
|
90
|
+
* Lower it to control span volume/cost on high-traffic apps. */
|
|
91
|
+
traceSampleRate?: number;
|
|
92
|
+
/**
|
|
93
|
+
* Capture uncaught browser errors (`window.onerror` + `unhandledrejection`) and
|
|
94
|
+
* export them as OTLP logs (`event.name=browser.error`, ERROR severity, with
|
|
95
|
+
* `exception.*` semconv attributes). **On by default** when `instrumentWeb` is
|
|
96
|
+
* called; set `false` to disable. Independent of replay and tracing — errors
|
|
97
|
+
* correlate to replays via `session_id` and to traces via the active span.
|
|
98
|
+
*/
|
|
99
|
+
errors?: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Capture `console` output as logs. `'error'` (default) captures `console.error`;
|
|
102
|
+
* `'warn'` captures `console.warn` **and** `console.error`; `false` disables it.
|
|
103
|
+
* Rate-capped and recursion-guarded so it can never feed a self-export loop.
|
|
104
|
+
*/
|
|
105
|
+
captureConsole?: "error" | "warn" | false;
|
|
68
106
|
}
|
|
69
107
|
/** Entry point: fetch config, decide sampling, start rrweb, stream chunks.
|
|
70
108
|
* Returns a stop() function. Safe to call in any browser; no-ops on the server. */
|
|
71
109
|
export declare function instrumentWeb(opts: InstrumentWebOptions): Promise<() => void>;
|
|
72
|
-
export declare function makeTraceparent(traceId: string, spanId: string): string;
|
|
110
|
+
export declare function makeTraceparent(traceId: string, spanId: string, sampled?: boolean): string;
|
|
73
111
|
/** Collects trace ids observed during a session (deduped, capped). */
|
|
74
112
|
export declare class TraceIdCollector {
|
|
75
113
|
private readonly cap;
|
|
@@ -78,7 +116,201 @@ export declare class TraceIdCollector {
|
|
|
78
116
|
add(id: string): void;
|
|
79
117
|
drain(): string[];
|
|
80
118
|
}
|
|
119
|
+
/** Holds the most recent browser CLIENT span context so an error captured during
|
|
120
|
+
* (or right after) a fetch can be tagged with that trace/span id. There is no
|
|
121
|
+
* ambient active-span stack in the browser SDK — spans are per-fetch — so this is
|
|
122
|
+
* a best-effort "last span" correlation, only populated when tracing is on. */
|
|
123
|
+
export declare class ActiveTraceRef {
|
|
124
|
+
private cur?;
|
|
125
|
+
set(traceId: string, spanId: string): void;
|
|
126
|
+
get(): {
|
|
127
|
+
traceId: string;
|
|
128
|
+
spanId: string;
|
|
129
|
+
} | undefined;
|
|
130
|
+
}
|
|
81
131
|
/** Patch window.fetch to inject traceparent on outgoing calls and record the
|
|
82
132
|
* trace id for correlation. Returns an unpatch function. */
|
|
83
133
|
export declare function patchFetchForCorrelation(collector: TraceIdCollector): () => void;
|
|
134
|
+
/** One recorded browser CLIENT span (an outbound fetch). */
|
|
135
|
+
export interface BrowserClientSpan {
|
|
136
|
+
traceId: string;
|
|
137
|
+
spanId: string;
|
|
138
|
+
name: string;
|
|
139
|
+
startMs: number;
|
|
140
|
+
endMs: number;
|
|
141
|
+
method: string;
|
|
142
|
+
url: string;
|
|
143
|
+
/** HTTP response status; 0 for a network error / throw. */
|
|
144
|
+
statusCode: number;
|
|
145
|
+
error: boolean;
|
|
146
|
+
}
|
|
147
|
+
/** Build an OTLP/JSON ExportTraceServiceRequest for a batch of client spans. */
|
|
148
|
+
export declare function buildTraceExport(service: string, spans: BrowserClientSpan[], resourceAttributes?: Record<string, string>): Record<string, unknown>;
|
|
149
|
+
export interface TraceExporterOpts {
|
|
150
|
+
endpoint: string;
|
|
151
|
+
apiKey: string;
|
|
152
|
+
service: string;
|
|
153
|
+
/** MUST be the ORIGINAL fetch captured before patching, or the export POST
|
|
154
|
+
* self-traces and loops (cost guardrail #1). */
|
|
155
|
+
fetchImpl: typeof fetch;
|
|
156
|
+
/** `service.version` / `service.build` — stamped on the resource for attribution. */
|
|
157
|
+
resourceAttributes?: Record<string, string>;
|
|
158
|
+
maxBatch?: number;
|
|
159
|
+
}
|
|
160
|
+
/** Buffers browser CLIENT spans and POSTs them as OTLP/JSON to /v1/traces. */
|
|
161
|
+
export declare class BrowserTraceExporter {
|
|
162
|
+
private readonly o;
|
|
163
|
+
private buf;
|
|
164
|
+
constructor(o: TraceExporterOpts);
|
|
165
|
+
add(span: BrowserClientSpan): void;
|
|
166
|
+
flush(keepalive?: boolean): Promise<void>;
|
|
167
|
+
}
|
|
168
|
+
export interface TracingPatchOpts {
|
|
169
|
+
onSpan: (s: BrowserClientSpan) => void;
|
|
170
|
+
sampleRate: number;
|
|
171
|
+
/** Bare ingest hostname; calls to it are never traced (self-export loop guard). */
|
|
172
|
+
ingestHost: string;
|
|
173
|
+
/** Optional: also record the real trace id for replay↔trace correlation. */
|
|
174
|
+
collector?: TraceIdCollector;
|
|
175
|
+
/** Optional: publish the current span context so captured errors can reference it. */
|
|
176
|
+
activeTrace?: ActiveTraceRef;
|
|
177
|
+
rng?: () => number;
|
|
178
|
+
}
|
|
179
|
+
/** Patch window.fetch to emit a real CLIENT span per outbound call, inject that
|
|
180
|
+
* span's W3C traceparent, and (head-sampled) hand the finished span to onSpan.
|
|
181
|
+
* Returns an unpatch function. */
|
|
182
|
+
export declare function patchFetchForTracing(o: TracingPatchOpts): () => void;
|
|
183
|
+
/** A browser log record before OTLP encoding. */
|
|
184
|
+
export interface BrowserLogRecord {
|
|
185
|
+
/** Client epoch millis. */
|
|
186
|
+
timeUnixMs: number;
|
|
187
|
+
severityText: "ERROR" | "WARN" | "INFO";
|
|
188
|
+
body: string;
|
|
189
|
+
attributes: Record<string, string>;
|
|
190
|
+
traceId?: string;
|
|
191
|
+
spanId?: string;
|
|
192
|
+
}
|
|
193
|
+
/** Build an OTLP/JSON ExportLogsServiceRequest for a batch of browser logs. */
|
|
194
|
+
export declare function buildLogsExport(service: string, records: BrowserLogRecord[], resourceAttributes?: Record<string, string>): Record<string, unknown>;
|
|
195
|
+
export interface LogsExporterOpts {
|
|
196
|
+
endpoint: string;
|
|
197
|
+
apiKey: string;
|
|
198
|
+
service: string;
|
|
199
|
+
/** MUST be the ORIGINAL fetch captured before patching, or the export POST
|
|
200
|
+
* self-traces and loops (cost guardrail #1). */
|
|
201
|
+
fetchImpl: typeof fetch;
|
|
202
|
+
/** `service.version` / `service.build` (from `version`/`build`) — stamped on the resource. */
|
|
203
|
+
resourceAttributes?: Record<string, string>;
|
|
204
|
+
maxBatch?: number;
|
|
205
|
+
}
|
|
206
|
+
/** Buffers browser logs and POSTs them as OTLP/JSON to /v1/logs. */
|
|
207
|
+
export declare class BrowserLogsExporter {
|
|
208
|
+
private readonly o;
|
|
209
|
+
private buf;
|
|
210
|
+
constructor(o: LogsExporterOpts);
|
|
211
|
+
add(r: BrowserLogRecord): void;
|
|
212
|
+
flush(keepalive?: boolean): Promise<void>;
|
|
213
|
+
}
|
|
214
|
+
/** Sliding-window rate gate: allow up to `cap` records per `windowMs`; count drops
|
|
215
|
+
* and report how many were dropped the moment a new window opens, so the caller
|
|
216
|
+
* can emit one summary record when the cap lifts. */
|
|
217
|
+
export declare class RateGate {
|
|
218
|
+
private readonly cap;
|
|
219
|
+
private readonly windowMs;
|
|
220
|
+
private readonly now;
|
|
221
|
+
private count;
|
|
222
|
+
private windowStart;
|
|
223
|
+
private dropped;
|
|
224
|
+
constructor(cap: number, windowMs: number, now?: () => number);
|
|
225
|
+
take(): {
|
|
226
|
+
allow: boolean;
|
|
227
|
+
recovered: number;
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/** One recently-captured browser error, kept cheap on purpose (message + type +
|
|
231
|
+
* timestamp only, no stack) so a bug report can attach the last N without holding
|
|
232
|
+
* large objects. Populated by startErrorCapture and read by reportBug(). */
|
|
233
|
+
export interface RecentError {
|
|
234
|
+
message: string;
|
|
235
|
+
type?: string;
|
|
236
|
+
/** Client epoch millis when the error was captured. */
|
|
237
|
+
timestamp: number;
|
|
238
|
+
}
|
|
239
|
+
/** Fixed-capacity ring of the most recent captured errors. Push is O(1); once the
|
|
240
|
+
* cap is reached the oldest entry is dropped. `list()` returns oldest→newest. */
|
|
241
|
+
export declare class RecentErrorsBuffer {
|
|
242
|
+
private readonly cap;
|
|
243
|
+
private buf;
|
|
244
|
+
constructor(cap?: number);
|
|
245
|
+
push(e: RecentError): void;
|
|
246
|
+
list(): RecentError[];
|
|
247
|
+
}
|
|
248
|
+
export interface ErrorCaptureOpts {
|
|
249
|
+
exporter: BrowserLogsExporter;
|
|
250
|
+
/** Correlates logs to the replay session and to each other. */
|
|
251
|
+
sessionId: string;
|
|
252
|
+
/** `'error'` = console.error; `'warn'` = warn + error; `false` = no console capture. */
|
|
253
|
+
captureConsole: "error" | "warn" | false;
|
|
254
|
+
activeTrace?: ActiveTraceRef;
|
|
255
|
+
/** Optional ring the capture path records each uncaught error into (message +
|
|
256
|
+
* type + timestamp), so reportBug() can attach recent errors as context. */
|
|
257
|
+
recent?: RecentErrorsBuffer;
|
|
258
|
+
now?: () => number;
|
|
259
|
+
/** Max records per window before dropping (default 60). */
|
|
260
|
+
rateCap?: number;
|
|
261
|
+
/** Rate window in ms (default 60_000). */
|
|
262
|
+
rateWindowMs?: number;
|
|
263
|
+
}
|
|
264
|
+
/** Wire up `window.onerror` / `unhandledrejection` (+ optional console) capture.
|
|
265
|
+
* Returns an unpatch function. Recursion-guarded (anything logged on our own
|
|
266
|
+
* export path is never re-captured) and rate-capped. */
|
|
267
|
+
export declare function startErrorCapture(o: ErrorCaptureOpts): () => void;
|
|
268
|
+
/** The user-supplied part of a bug report. `message` is required; everything else
|
|
269
|
+
* the SDK attaches automatically. `context` is arbitrary app metadata (plan,
|
|
270
|
+
* feature flag, screen…) stored verbatim. */
|
|
271
|
+
export interface BugReport {
|
|
272
|
+
message: string;
|
|
273
|
+
/** Optional reporter email so the team can follow up. */
|
|
274
|
+
email?: string;
|
|
275
|
+
/** Arbitrary string metadata the app wants to attach (values are stored as-is). */
|
|
276
|
+
context?: Record<string, string>;
|
|
277
|
+
}
|
|
278
|
+
/** The session context reportBug() draws on, registered by instrumentWeb() and
|
|
279
|
+
* cleared on stop(). Kept separate from InstrumentWebOptions so the module-level
|
|
280
|
+
* reportBug() has everything it needs without re-reading config. */
|
|
281
|
+
export interface BugReportSession {
|
|
282
|
+
endpoint: string;
|
|
283
|
+
apiKey: string;
|
|
284
|
+
service: string;
|
|
285
|
+
/** Shared with browser errors / console logs / replay — the correlation key. */
|
|
286
|
+
sessionId: string;
|
|
287
|
+
/** ORIGINAL fetch captured before patching (self-export-loop guard). */
|
|
288
|
+
fetchImpl: typeof fetch;
|
|
289
|
+
/** Last browser CLIENT span, so a report references the in-flight trace. */
|
|
290
|
+
activeTrace: ActiveTraceRef;
|
|
291
|
+
/** Ring of recent captured errors attached as report context. */
|
|
292
|
+
recentErrors: RecentErrorsBuffer;
|
|
293
|
+
/** Emits the user.bug_report OTLP log so the report shows in the timeline. */
|
|
294
|
+
logsExporter: BrowserLogsExporter;
|
|
295
|
+
version?: string;
|
|
296
|
+
build?: string;
|
|
297
|
+
}
|
|
298
|
+
/** Internal: instrumentWeb() registers the active session here (and clears it with
|
|
299
|
+
* `null` on stop) so the module-level reportBug() works after instrumentWeb() is
|
|
300
|
+
* called. Exported for that wiring + tests; not part of the app-facing surface. */
|
|
301
|
+
export declare function registerBugSession(session: BugReportSession | null): void;
|
|
302
|
+
/** Build the /v1/bug-reports POST body: the user's fields plus the auto-attached
|
|
303
|
+
* session/trace/release context and recent errors. Pure (env reads are guarded)
|
|
304
|
+
* so it is unit-testable. */
|
|
305
|
+
export declare function buildBugReportPayload(s: BugReportSession, report: BugReport): Record<string, unknown>;
|
|
306
|
+
/**
|
|
307
|
+
* File an in-app bug report. Call after instrumentWeb() — it throws if the SDK
|
|
308
|
+
* isn't initialised or the message is empty (both are programmer errors). Network
|
|
309
|
+
* failures are swallowed (a failed report must never break the host app).
|
|
310
|
+
*
|
|
311
|
+
* @example
|
|
312
|
+
* import { reportBug } from "@heystack/otel/web";
|
|
313
|
+
* button.onclick = () => reportBug({ message: input.value, email: userEmail });
|
|
314
|
+
*/
|
|
315
|
+
export declare function reportBug(report: BugReport): Promise<void>;
|
|
84
316
|
export {};
|
package/dist/web.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import { DEFAULT_ENDPOINT } from "./core.js";
|
|
1
|
+
import { DEFAULT_ENDPOINT, releaseResourceAttributes } from "./core.js";
|
|
2
|
+
// safeHostname is pure string logic (no runtime imports) — safe in the browser
|
|
3
|
+
// bundle. Used to skip tracing our OWN telemetry POSTs (the self-export loop that
|
|
4
|
+
// caused the June 2026 cost incident); see CLAUDE.md → "Cost guardrails".
|
|
5
|
+
import { safeHostname } from "./self-span.js";
|
|
2
6
|
/** Pure: decide once per session whether to record. rng defaults to Math.random. */
|
|
3
7
|
export function shouldRecord(cfg, rng = Math.random) {
|
|
4
8
|
if (!cfg.enabled)
|
|
@@ -93,10 +97,90 @@ export async function instrumentWeb(opts) {
|
|
|
93
97
|
catch { /* offline / blocked - fall back to disabled */ }
|
|
94
98
|
if (opts.sampleRate !== undefined)
|
|
95
99
|
cfg = { ...cfg, sample_rate: opts.sampleRate };
|
|
96
|
-
//
|
|
100
|
+
// Capture the ORIGINAL fetch before any patching — telemetry exporters MUST use
|
|
101
|
+
// it so their own POSTs to the ingest endpoint aren't traced/looped (guardrail #1).
|
|
102
|
+
const originalFetch = fetch.bind(globalThis);
|
|
103
|
+
const ingestHost = safeHostname(endpoint);
|
|
104
|
+
const traces = new TraceIdCollector();
|
|
105
|
+
const activeTrace = new ActiveTraceRef();
|
|
106
|
+
// One session id for the whole run — shared by errors, console logs AND replay
|
|
107
|
+
// (created here, before the replay sampling gate, so errors correlate even when
|
|
108
|
+
// replay isn't sampled; when it is, they share this id → error ↔ replay linking).
|
|
109
|
+
const sessionId = crypto.randomUUID();
|
|
110
|
+
// service.version / service.build for release attribution on exported browser logs.
|
|
111
|
+
const releaseAttrs = releaseResourceAttributes({ version: opts.version, build: opts.build });
|
|
112
|
+
// 2. Browser distributed tracing (opt-in) — INDEPENDENT of replay sampling. Emits
|
|
113
|
+
// a real CLIENT span per outbound fetch + propagates W3C context to the backend.
|
|
114
|
+
let stopTracing = () => { };
|
|
115
|
+
let fetchPatched = false;
|
|
116
|
+
if (opts.tracing) {
|
|
117
|
+
const exporter = new BrowserTraceExporter({
|
|
118
|
+
endpoint, apiKey: opts.apiKey, service: opts.service, fetchImpl: originalFetch,
|
|
119
|
+
resourceAttributes: releaseAttrs,
|
|
120
|
+
});
|
|
121
|
+
const unpatchTrace = patchFetchForTracing({
|
|
122
|
+
onSpan: (s) => exporter.add(s),
|
|
123
|
+
sampleRate: opts.traceSampleRate ?? 1,
|
|
124
|
+
ingestHost,
|
|
125
|
+
collector: traces, // real trace ids also tag the replay session (better correlation)
|
|
126
|
+
activeTrace, // so a captured error can reference the in-flight span
|
|
127
|
+
});
|
|
128
|
+
fetchPatched = true;
|
|
129
|
+
const traceFlush = setInterval(() => void exporter.flush(), opts.flushIntervalMs ?? DEFAULT_FLUSH_MS);
|
|
130
|
+
const onHideTrace = () => { if (document.visibilityState === "hidden")
|
|
131
|
+
void exporter.flush(true); };
|
|
132
|
+
document.addEventListener("visibilitychange", onHideTrace);
|
|
133
|
+
window.addEventListener("pagehide", () => void exporter.flush(true));
|
|
134
|
+
stopTracing = () => {
|
|
135
|
+
clearInterval(traceFlush);
|
|
136
|
+
document.removeEventListener("visibilitychange", onHideTrace);
|
|
137
|
+
unpatchTrace();
|
|
138
|
+
void exporter.flush(true);
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
// The logs exporter + recent-errors ring are shared by error capture AND the
|
|
142
|
+
// in-app bug reporter, so they're constructed here (before the errors gate) and
|
|
143
|
+
// always available to reportBug() even when error capture is disabled. The
|
|
144
|
+
// exporter is inert until something is added, so constructing it eagerly is free.
|
|
145
|
+
const logsExporter = new BrowserLogsExporter({
|
|
146
|
+
endpoint, apiKey: opts.apiKey, service: opts.service, fetchImpl: originalFetch,
|
|
147
|
+
resourceAttributes: releaseAttrs,
|
|
148
|
+
});
|
|
149
|
+
const recentErrors = new RecentErrorsBuffer(20);
|
|
150
|
+
// Register the session so the module-level reportBug() works after this call.
|
|
151
|
+
registerBugSession({
|
|
152
|
+
endpoint, apiKey: opts.apiKey, service: opts.service, sessionId,
|
|
153
|
+
fetchImpl: originalFetch, activeTrace, recentErrors, logsExporter,
|
|
154
|
+
version: opts.version, build: opts.build,
|
|
155
|
+
});
|
|
156
|
+
// 2b. Browser error / console log collection — ON by default, INDEPENDENT of both
|
|
157
|
+
// tracing and replay sampling. Exports OTLP logs to /v1/logs via the original fetch.
|
|
158
|
+
let stopErrors = () => { };
|
|
159
|
+
if (opts.errors !== false) {
|
|
160
|
+
const unpatchErrors = startErrorCapture({
|
|
161
|
+
exporter: logsExporter,
|
|
162
|
+
sessionId,
|
|
163
|
+
captureConsole: opts.captureConsole ?? "error",
|
|
164
|
+
activeTrace,
|
|
165
|
+
recent: recentErrors,
|
|
166
|
+
});
|
|
167
|
+
const logFlush = setInterval(() => void logsExporter.flush(), opts.flushIntervalMs ?? DEFAULT_FLUSH_MS);
|
|
168
|
+
const onHideLogs = () => { if (document.visibilityState === "hidden")
|
|
169
|
+
void logsExporter.flush(true); };
|
|
170
|
+
const onPageHideLogs = () => void logsExporter.flush(true);
|
|
171
|
+
document.addEventListener("visibilitychange", onHideLogs);
|
|
172
|
+
window.addEventListener("pagehide", onPageHideLogs);
|
|
173
|
+
stopErrors = () => {
|
|
174
|
+
clearInterval(logFlush);
|
|
175
|
+
document.removeEventListener("visibilitychange", onHideLogs);
|
|
176
|
+
window.removeEventListener("pagehide", onPageHideLogs);
|
|
177
|
+
unpatchErrors();
|
|
178
|
+
void logsExporter.flush(true);
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
// 3. Session replay — gated on the replay sampling decision (independent of tracing/errors).
|
|
97
182
|
if (!shouldRecord(cfg))
|
|
98
|
-
return () => { };
|
|
99
|
-
// 3. Start the recorder.
|
|
183
|
+
return () => { stopTracing(); stopErrors(); registerBugSession(null); };
|
|
100
184
|
const { record } = await import("rrweb");
|
|
101
185
|
// An element marked `data-hs-unmask` (or any descendant of one) is recorded in
|
|
102
186
|
// cleartext; everything else is masked. rrweb 2.0.1's real opt-out hooks are
|
|
@@ -108,13 +192,11 @@ export async function instrumentWeb(opts) {
|
|
|
108
192
|
const reveal = (text, element) => !!element?.closest?.("[data-hs-unmask]");
|
|
109
193
|
const maskInputFn = (text, element) => reveal(text, element) ? text : "*".repeat(text.length);
|
|
110
194
|
const maskTextFn = (text, element) => reveal(text, element) ? text : "*".repeat(text.length);
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
const traces = new TraceIdCollector();
|
|
117
|
-
const unpatch = patchFetchForCorrelation(traces);
|
|
195
|
+
const transport = new ReplayTransport({ endpoint, apiKey: opts.apiKey, fetchImpl: originalFetch, sessionId });
|
|
196
|
+
// Only patch fetch for replay correlation if tracing didn't already patch it
|
|
197
|
+
// (tracing's patch injects real context AND feeds `traces`). Avoids double-wrapping
|
|
198
|
+
// window.fetch. Uses the original fetch for uploads (self-span suppression).
|
|
199
|
+
const unpatch = fetchPatched ? () => { } : patchFetchForCorrelation(traces);
|
|
118
200
|
let buffer = [];
|
|
119
201
|
let errorCount = 0;
|
|
120
202
|
const browser = navigator.userAgent;
|
|
@@ -135,11 +217,19 @@ export async function instrumentWeb(opts) {
|
|
|
135
217
|
}, keepalive);
|
|
136
218
|
};
|
|
137
219
|
const stopRecord = record({
|
|
138
|
-
emit(event) {
|
|
220
|
+
emit(event, isCheckout) {
|
|
221
|
+
// rrweb re-checkpoints (takes a fresh FULL_SNAPSHOT) every `checkoutEveryNms`.
|
|
222
|
+
// Flush the buffer BEFORE appending the checkout event so each keyframe starts
|
|
223
|
+
// a new chunk (has_snapshot=true) — the player can then begin playback from the
|
|
224
|
+
// nearest keyframe ≤ a target time instead of loading the whole session.
|
|
225
|
+
if (isCheckout)
|
|
226
|
+
flush();
|
|
139
227
|
buffer.push(event);
|
|
140
228
|
if (buffer.length >= (opts.flushEveryEvents ?? DEFAULT_FLUSH_EVENTS))
|
|
141
229
|
flush();
|
|
142
230
|
},
|
|
231
|
+
// A ~60s keyframe cadence bounds how many chunks the player must fetch to seek.
|
|
232
|
+
checkoutEveryNms: 60_000,
|
|
143
233
|
maskAllInputs: cfg.masking_mode === "strict",
|
|
144
234
|
maskTextSelector: "[data-hs-mask]",
|
|
145
235
|
blockSelector: "[data-hs-block]",
|
|
@@ -159,6 +249,9 @@ export async function instrumentWeb(opts) {
|
|
|
159
249
|
stopRecord?.();
|
|
160
250
|
unpatch();
|
|
161
251
|
flush(true);
|
|
252
|
+
stopTracing();
|
|
253
|
+
stopErrors();
|
|
254
|
+
registerBugSession(null);
|
|
162
255
|
};
|
|
163
256
|
}
|
|
164
257
|
function matchMediaDevice() {
|
|
@@ -171,8 +264,11 @@ function randHex(bytes) {
|
|
|
171
264
|
crypto.getRandomValues(a);
|
|
172
265
|
return Array.from(a, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
173
266
|
}
|
|
174
|
-
export function makeTraceparent(traceId, spanId) {
|
|
175
|
-
|
|
267
|
+
export function makeTraceparent(traceId, spanId, sampled = true) {
|
|
268
|
+
// The trace-flags byte's low bit is "sampled". When head sampling drops a
|
|
269
|
+
// request we still inject the context (00) so the downstream service makes the
|
|
270
|
+
// SAME keep/drop decision — coordinated sampling, no orphaned server spans.
|
|
271
|
+
return `00-${traceId}-${spanId}-${sampled ? "01" : "00"}`;
|
|
176
272
|
}
|
|
177
273
|
/** Collects trace ids observed during a session (deduped, capped). */
|
|
178
274
|
export class TraceIdCollector {
|
|
@@ -192,6 +288,15 @@ export class TraceIdCollector {
|
|
|
192
288
|
return out;
|
|
193
289
|
}
|
|
194
290
|
}
|
|
291
|
+
/** Holds the most recent browser CLIENT span context so an error captured during
|
|
292
|
+
* (or right after) a fetch can be tagged with that trace/span id. There is no
|
|
293
|
+
* ambient active-span stack in the browser SDK — spans are per-fetch — so this is
|
|
294
|
+
* a best-effort "last span" correlation, only populated when tracing is on. */
|
|
295
|
+
export class ActiveTraceRef {
|
|
296
|
+
cur;
|
|
297
|
+
set(traceId, spanId) { this.cur = { traceId, spanId }; }
|
|
298
|
+
get() { return this.cur; }
|
|
299
|
+
}
|
|
195
300
|
/** Patch window.fetch to inject traceparent on outgoing calls and record the
|
|
196
301
|
* trace id for correlation. Returns an unpatch function. */
|
|
197
302
|
export function patchFetchForCorrelation(collector) {
|
|
@@ -209,3 +314,445 @@ export function patchFetchForCorrelation(collector) {
|
|
|
209
314
|
});
|
|
210
315
|
return () => { window.fetch = orig; };
|
|
211
316
|
}
|
|
317
|
+
const kvStr = (key, value) => ({ key, value: { stringValue: value } });
|
|
318
|
+
const kvInt = (key, n) => ({ key, value: { intValue: String(n) } });
|
|
319
|
+
const msToNano = (ms) => `${Math.trunc(ms)}000000`;
|
|
320
|
+
function spanToOtlp(s) {
|
|
321
|
+
const attributes = [
|
|
322
|
+
kvStr("http.request.method", s.method),
|
|
323
|
+
kvStr("url.full", s.url),
|
|
324
|
+
kvStr("server.address", safeHostname(s.url)),
|
|
325
|
+
];
|
|
326
|
+
if (s.statusCode > 0)
|
|
327
|
+
attributes.push(kvInt("http.response.status_code", s.statusCode));
|
|
328
|
+
return {
|
|
329
|
+
traceId: s.traceId,
|
|
330
|
+
spanId: s.spanId,
|
|
331
|
+
name: s.name,
|
|
332
|
+
kind: 3, // SPAN_KIND CLIENT
|
|
333
|
+
startTimeUnixNano: msToNano(s.startMs),
|
|
334
|
+
endTimeUnixNano: msToNano(s.endMs),
|
|
335
|
+
attributes,
|
|
336
|
+
status: { code: s.error ? 2 : 1 }, // STATUS_CODE ERROR : OK
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
/** Build an OTLP/JSON ExportTraceServiceRequest for a batch of client spans. */
|
|
340
|
+
export function buildTraceExport(service, spans, resourceAttributes = {}) {
|
|
341
|
+
const resAttrs = [
|
|
342
|
+
kvStr("service.name", service),
|
|
343
|
+
...Object.entries(resourceAttributes).map(([k, v]) => kvStr(k, v)),
|
|
344
|
+
];
|
|
345
|
+
return {
|
|
346
|
+
resourceSpans: [
|
|
347
|
+
{
|
|
348
|
+
resource: { attributes: resAttrs },
|
|
349
|
+
scopeSpans: [{ scope: { name: "@heystack/otel/web" }, spans: spans.map(spanToOtlp) }],
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
/** Buffers browser CLIENT spans and POSTs them as OTLP/JSON to /v1/traces. */
|
|
355
|
+
export class BrowserTraceExporter {
|
|
356
|
+
o;
|
|
357
|
+
buf = [];
|
|
358
|
+
constructor(o) {
|
|
359
|
+
this.o = o;
|
|
360
|
+
}
|
|
361
|
+
add(span) {
|
|
362
|
+
this.buf.push(span);
|
|
363
|
+
if (this.buf.length >= (this.o.maxBatch ?? 50))
|
|
364
|
+
void this.flush();
|
|
365
|
+
}
|
|
366
|
+
async flush(keepalive = false) {
|
|
367
|
+
if (this.buf.length === 0)
|
|
368
|
+
return;
|
|
369
|
+
const spans = this.buf;
|
|
370
|
+
this.buf = [];
|
|
371
|
+
const body = JSON.stringify(buildTraceExport(this.o.service, spans, this.o.resourceAttributes));
|
|
372
|
+
await this.o
|
|
373
|
+
.fetchImpl(`${this.o.endpoint}/v1/traces`, {
|
|
374
|
+
method: "POST",
|
|
375
|
+
keepalive,
|
|
376
|
+
headers: { authorization: `Bearer ${this.o.apiKey}`, "content-type": "application/json" },
|
|
377
|
+
body,
|
|
378
|
+
})
|
|
379
|
+
.catch(() => { });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function fetchUrl(input) {
|
|
383
|
+
if (typeof input === "string")
|
|
384
|
+
return input;
|
|
385
|
+
if (input instanceof URL)
|
|
386
|
+
return input.href;
|
|
387
|
+
return input.url ?? "";
|
|
388
|
+
}
|
|
389
|
+
function fetchMethod(input, init) {
|
|
390
|
+
const m = init?.method ?? (input instanceof Request ? input.method : undefined) ?? "GET";
|
|
391
|
+
return m.toUpperCase();
|
|
392
|
+
}
|
|
393
|
+
function pathOf(url) {
|
|
394
|
+
try {
|
|
395
|
+
return new URL(url).pathname;
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
return url;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/** Patch window.fetch to emit a real CLIENT span per outbound call, inject that
|
|
402
|
+
* span's W3C traceparent, and (head-sampled) hand the finished span to onSpan.
|
|
403
|
+
* Returns an unpatch function. */
|
|
404
|
+
export function patchFetchForTracing(o) {
|
|
405
|
+
if (typeof window === "undefined" || !window.fetch)
|
|
406
|
+
return () => { };
|
|
407
|
+
const orig = window.fetch.bind(window);
|
|
408
|
+
const rng = o.rng ?? Math.random;
|
|
409
|
+
window.fetch = ((input, init) => {
|
|
410
|
+
const url = fetchUrl(input);
|
|
411
|
+
// Never trace our own telemetry POSTs (trace export, replay upload, config) —
|
|
412
|
+
// they'd create spans that export → re-trace → loop. The exporter already uses
|
|
413
|
+
// the original fetch; this is the belt-and-suspenders host-match guard.
|
|
414
|
+
if (o.ingestHost && safeHostname(url) === o.ingestHost)
|
|
415
|
+
return orig(input, init);
|
|
416
|
+
const sampled = rng() < o.sampleRate;
|
|
417
|
+
const traceId = randHex(16);
|
|
418
|
+
const spanId = randHex(8);
|
|
419
|
+
if (sampled && o.collector)
|
|
420
|
+
o.collector.add(traceId);
|
|
421
|
+
if (sampled)
|
|
422
|
+
o.activeTrace?.set(traceId, spanId);
|
|
423
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
424
|
+
if (!headers.has("traceparent"))
|
|
425
|
+
headers.set("traceparent", makeTraceparent(traceId, spanId, sampled));
|
|
426
|
+
const method = fetchMethod(input, init);
|
|
427
|
+
const startMs = Date.now();
|
|
428
|
+
const emit = (statusCode, error) => {
|
|
429
|
+
if (!sampled)
|
|
430
|
+
return;
|
|
431
|
+
o.onSpan({ traceId, spanId, name: `${method} ${pathOf(url)}`, startMs, endMs: Date.now(), method, url, statusCode, error });
|
|
432
|
+
};
|
|
433
|
+
return orig(input, { ...init, headers }).then((res) => { emit(res.status, res.status >= 400); return res; }, (err) => { emit(0, true); throw err; });
|
|
434
|
+
});
|
|
435
|
+
return () => { window.fetch = orig; };
|
|
436
|
+
}
|
|
437
|
+
// ── Browser error / console log collection ──────────────────────────────────
|
|
438
|
+
// Uncaught errors + (opt) console output become OTLP logs POSTed to /v1/logs.
|
|
439
|
+
// No official OTel browser *logs* SDK exists, so we write the thin exporter here
|
|
440
|
+
// and keep the OSS wire format (OTLP/JSON LogsRequest, matching what /v1/logs and
|
|
441
|
+
// schema's logsToRows parse). Same cost guardrail as traces/replay: the exporter
|
|
442
|
+
// MUST be handed the ORIGINAL fetch (captured before patching) so its own POST is
|
|
443
|
+
// never traced → re-exported → looped.
|
|
444
|
+
/** OTel severity numbers for the levels we emit (see OTLP LogRecord spec).
|
|
445
|
+
* INFO is used by non-error events such as a user bug report. */
|
|
446
|
+
const SEVERITY_NUMBER = { ERROR: 17, WARN: 13, INFO: 9 };
|
|
447
|
+
/** Max characters of a log body we keep (console args / messages can be huge). */
|
|
448
|
+
const MAX_LOG_BODY = 4096;
|
|
449
|
+
function logToOtlp(r) {
|
|
450
|
+
const rec = {
|
|
451
|
+
timeUnixNano: msToNano(r.timeUnixMs),
|
|
452
|
+
severityText: r.severityText,
|
|
453
|
+
severityNumber: SEVERITY_NUMBER[r.severityText],
|
|
454
|
+
body: { stringValue: r.body },
|
|
455
|
+
attributes: Object.entries(r.attributes).map(([k, v]) => kvStr(k, v)),
|
|
456
|
+
};
|
|
457
|
+
if (r.traceId)
|
|
458
|
+
rec.traceId = r.traceId;
|
|
459
|
+
if (r.spanId)
|
|
460
|
+
rec.spanId = r.spanId;
|
|
461
|
+
return rec;
|
|
462
|
+
}
|
|
463
|
+
/** Build an OTLP/JSON ExportLogsServiceRequest for a batch of browser logs. */
|
|
464
|
+
export function buildLogsExport(service, records, resourceAttributes = {}) {
|
|
465
|
+
const resAttrs = [
|
|
466
|
+
kvStr("service.name", service),
|
|
467
|
+
...Object.entries(resourceAttributes).map(([k, v]) => kvStr(k, v)),
|
|
468
|
+
];
|
|
469
|
+
return {
|
|
470
|
+
resourceLogs: [
|
|
471
|
+
{
|
|
472
|
+
resource: { attributes: resAttrs },
|
|
473
|
+
scopeLogs: [{ scope: { name: "@heystack/otel/web" }, logRecords: records.map(logToOtlp) }],
|
|
474
|
+
},
|
|
475
|
+
],
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
/** Buffers browser logs and POSTs them as OTLP/JSON to /v1/logs. */
|
|
479
|
+
export class BrowserLogsExporter {
|
|
480
|
+
o;
|
|
481
|
+
buf = [];
|
|
482
|
+
constructor(o) {
|
|
483
|
+
this.o = o;
|
|
484
|
+
}
|
|
485
|
+
add(r) {
|
|
486
|
+
this.buf.push(r);
|
|
487
|
+
if (this.buf.length >= (this.o.maxBatch ?? 50))
|
|
488
|
+
void this.flush();
|
|
489
|
+
}
|
|
490
|
+
async flush(keepalive = false) {
|
|
491
|
+
if (this.buf.length === 0)
|
|
492
|
+
return;
|
|
493
|
+
const records = this.buf;
|
|
494
|
+
this.buf = [];
|
|
495
|
+
const body = JSON.stringify(buildLogsExport(this.o.service, records, this.o.resourceAttributes));
|
|
496
|
+
await this.o
|
|
497
|
+
.fetchImpl(`${this.o.endpoint}/v1/logs`, {
|
|
498
|
+
method: "POST",
|
|
499
|
+
keepalive,
|
|
500
|
+
headers: { authorization: `Bearer ${this.o.apiKey}`, "content-type": "application/json" },
|
|
501
|
+
body,
|
|
502
|
+
})
|
|
503
|
+
.catch(() => { });
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/** Sliding-window rate gate: allow up to `cap` records per `windowMs`; count drops
|
|
507
|
+
* and report how many were dropped the moment a new window opens, so the caller
|
|
508
|
+
* can emit one summary record when the cap lifts. */
|
|
509
|
+
export class RateGate {
|
|
510
|
+
cap;
|
|
511
|
+
windowMs;
|
|
512
|
+
now;
|
|
513
|
+
count = 0;
|
|
514
|
+
windowStart = Number.NEGATIVE_INFINITY;
|
|
515
|
+
dropped = 0;
|
|
516
|
+
constructor(cap, windowMs, now = Date.now) {
|
|
517
|
+
this.cap = cap;
|
|
518
|
+
this.windowMs = windowMs;
|
|
519
|
+
this.now = now;
|
|
520
|
+
}
|
|
521
|
+
take() {
|
|
522
|
+
const t = this.now();
|
|
523
|
+
let recovered = 0;
|
|
524
|
+
if (t - this.windowStart >= this.windowMs) {
|
|
525
|
+
recovered = this.dropped;
|
|
526
|
+
this.dropped = 0;
|
|
527
|
+
this.count = 0;
|
|
528
|
+
this.windowStart = t;
|
|
529
|
+
}
|
|
530
|
+
if (this.count >= this.cap) {
|
|
531
|
+
this.dropped++;
|
|
532
|
+
return { allow: false, recovered };
|
|
533
|
+
}
|
|
534
|
+
this.count++;
|
|
535
|
+
return { allow: true, recovered };
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
/** Best-effort stringify of a console arg: strings pass through; Errors keep their
|
|
539
|
+
* stack; objects are JSON'd (circular refs degrade to their tag). */
|
|
540
|
+
function stringifyArg(a) {
|
|
541
|
+
if (typeof a === "string")
|
|
542
|
+
return a;
|
|
543
|
+
if (a instanceof Error)
|
|
544
|
+
return a.stack ?? `${a.name}: ${a.message}`;
|
|
545
|
+
try {
|
|
546
|
+
return JSON.stringify(a) ?? String(a);
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
return Object.prototype.toString.call(a);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
/** Fixed-capacity ring of the most recent captured errors. Push is O(1); once the
|
|
553
|
+
* cap is reached the oldest entry is dropped. `list()` returns oldest→newest. */
|
|
554
|
+
export class RecentErrorsBuffer {
|
|
555
|
+
cap;
|
|
556
|
+
buf = [];
|
|
557
|
+
constructor(cap = 20) {
|
|
558
|
+
this.cap = cap;
|
|
559
|
+
}
|
|
560
|
+
push(e) {
|
|
561
|
+
this.buf.push(e);
|
|
562
|
+
if (this.buf.length > this.cap)
|
|
563
|
+
this.buf.shift();
|
|
564
|
+
}
|
|
565
|
+
list() {
|
|
566
|
+
return [...this.buf];
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
/** Wire up `window.onerror` / `unhandledrejection` (+ optional console) capture.
|
|
570
|
+
* Returns an unpatch function. Recursion-guarded (anything logged on our own
|
|
571
|
+
* export path is never re-captured) and rate-capped. */
|
|
572
|
+
export function startErrorCapture(o) {
|
|
573
|
+
if (typeof window === "undefined")
|
|
574
|
+
return () => { };
|
|
575
|
+
const now = o.now ?? Date.now;
|
|
576
|
+
const gate = new RateGate(o.rateCap ?? 60, o.rateWindowMs ?? 60_000, now);
|
|
577
|
+
// Re-entrancy flag: if capturing a record ends up invoking a patched console
|
|
578
|
+
// (or any path that re-enters here), we drop the nested call — this is what
|
|
579
|
+
// stops a console.error emitted while building/exporting a record from looping.
|
|
580
|
+
let inCapture = false;
|
|
581
|
+
const push = (rec) => {
|
|
582
|
+
const { allow, recovered } = gate.take();
|
|
583
|
+
if (recovered > 0) {
|
|
584
|
+
o.exporter.add({
|
|
585
|
+
timeUnixMs: now(),
|
|
586
|
+
severityText: "WARN",
|
|
587
|
+
body: `${recovered} browser log record(s) dropped (rate cap)`,
|
|
588
|
+
attributes: { "event.name": "heystack.rate_limited", session_id: o.sessionId },
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
if (!allow)
|
|
592
|
+
return;
|
|
593
|
+
o.exporter.add(rec);
|
|
594
|
+
};
|
|
595
|
+
const emitError = (e) => {
|
|
596
|
+
if (inCapture)
|
|
597
|
+
return;
|
|
598
|
+
inCapture = true;
|
|
599
|
+
try {
|
|
600
|
+
// Record into the recent-errors ring (cheap: message + type + timestamp) so
|
|
601
|
+
// a later reportBug() can attach it. Done before the rate gate so a captured
|
|
602
|
+
// error is always available as bug context even when log export is throttled.
|
|
603
|
+
o.recent?.push({ message: e.message, type: e.type, timestamp: now() });
|
|
604
|
+
const attrs = {
|
|
605
|
+
"event.name": "browser.error",
|
|
606
|
+
"url.full": location.href,
|
|
607
|
+
session_id: o.sessionId,
|
|
608
|
+
"exception.message": e.message,
|
|
609
|
+
};
|
|
610
|
+
if (e.type)
|
|
611
|
+
attrs["exception.type"] = e.type;
|
|
612
|
+
if (e.stack)
|
|
613
|
+
attrs["exception.stacktrace"] = e.stack;
|
|
614
|
+
const active = o.activeTrace?.get();
|
|
615
|
+
push({
|
|
616
|
+
timeUnixMs: now(),
|
|
617
|
+
severityText: "ERROR",
|
|
618
|
+
body: e.message,
|
|
619
|
+
attributes: attrs,
|
|
620
|
+
traceId: active?.traceId,
|
|
621
|
+
spanId: active?.spanId,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
finally {
|
|
625
|
+
inCapture = false;
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
const onError = (ev) => {
|
|
629
|
+
const err = ev.error;
|
|
630
|
+
emitError({
|
|
631
|
+
message: ev.message || err?.message || "Error",
|
|
632
|
+
type: err?.name,
|
|
633
|
+
stack: err?.stack,
|
|
634
|
+
});
|
|
635
|
+
};
|
|
636
|
+
const onRejection = (ev) => {
|
|
637
|
+
const reason = ev.reason;
|
|
638
|
+
const isErr = reason instanceof Error;
|
|
639
|
+
emitError({
|
|
640
|
+
message: isErr ? reason.message : String(reason),
|
|
641
|
+
type: isErr ? reason.name : "UnhandledRejection",
|
|
642
|
+
stack: isErr ? reason.stack : undefined,
|
|
643
|
+
});
|
|
644
|
+
};
|
|
645
|
+
window.addEventListener("error", onError);
|
|
646
|
+
window.addEventListener("unhandledrejection", onRejection);
|
|
647
|
+
// Console capture (optional) — patches console methods, always calling the
|
|
648
|
+
// ORIGINAL first so host behaviour is unchanged.
|
|
649
|
+
const levels = o.captureConsole === "warn" ? ["warn", "error"] : o.captureConsole === "error" ? ["error"] : [];
|
|
650
|
+
const originals = {};
|
|
651
|
+
for (const level of levels) {
|
|
652
|
+
const orig = console[level];
|
|
653
|
+
originals[level] = orig;
|
|
654
|
+
console[level] = ((...args) => {
|
|
655
|
+
orig.apply(console, args);
|
|
656
|
+
if (inCapture)
|
|
657
|
+
return;
|
|
658
|
+
inCapture = true;
|
|
659
|
+
try {
|
|
660
|
+
const body = args.map(stringifyArg).join(" ").slice(0, MAX_LOG_BODY);
|
|
661
|
+
const active = o.activeTrace?.get();
|
|
662
|
+
push({
|
|
663
|
+
timeUnixMs: now(),
|
|
664
|
+
severityText: level === "error" ? "ERROR" : "WARN",
|
|
665
|
+
body,
|
|
666
|
+
attributes: {
|
|
667
|
+
"event.name": "browser.console",
|
|
668
|
+
"console.level": level,
|
|
669
|
+
"url.full": location.href,
|
|
670
|
+
session_id: o.sessionId,
|
|
671
|
+
},
|
|
672
|
+
traceId: active?.traceId,
|
|
673
|
+
spanId: active?.spanId,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
finally {
|
|
677
|
+
inCapture = false;
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
return () => {
|
|
682
|
+
window.removeEventListener("error", onError);
|
|
683
|
+
window.removeEventListener("unhandledrejection", onRejection);
|
|
684
|
+
for (const level of levels) {
|
|
685
|
+
const orig = originals[level];
|
|
686
|
+
if (orig)
|
|
687
|
+
console[level] = orig;
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
let activeBugSession = null;
|
|
692
|
+
/** Internal: instrumentWeb() registers the active session here (and clears it with
|
|
693
|
+
* `null` on stop) so the module-level reportBug() works after instrumentWeb() is
|
|
694
|
+
* called. Exported for that wiring + tests; not part of the app-facing surface. */
|
|
695
|
+
export function registerBugSession(session) {
|
|
696
|
+
activeBugSession = session;
|
|
697
|
+
}
|
|
698
|
+
/** Build the /v1/bug-reports POST body: the user's fields plus the auto-attached
|
|
699
|
+
* session/trace/release context and recent errors. Pure (env reads are guarded)
|
|
700
|
+
* so it is unit-testable. */
|
|
701
|
+
export function buildBugReportPayload(s, report) {
|
|
702
|
+
const active = s.activeTrace.get();
|
|
703
|
+
return {
|
|
704
|
+
message: report.message,
|
|
705
|
+
email: report.email,
|
|
706
|
+
context: report.context,
|
|
707
|
+
url: typeof location !== "undefined" ? location.href : undefined,
|
|
708
|
+
user_agent: typeof navigator !== "undefined" ? navigator.userAgent : undefined,
|
|
709
|
+
session_id: s.sessionId,
|
|
710
|
+
trace_id: active?.traceId,
|
|
711
|
+
release: s.version,
|
|
712
|
+
build: s.build,
|
|
713
|
+
recent_errors: s.recentErrors.list(),
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* File an in-app bug report. Call after instrumentWeb() — it throws if the SDK
|
|
718
|
+
* isn't initialised or the message is empty (both are programmer errors). Network
|
|
719
|
+
* failures are swallowed (a failed report must never break the host app).
|
|
720
|
+
*
|
|
721
|
+
* @example
|
|
722
|
+
* import { reportBug } from "@heystack/otel/web";
|
|
723
|
+
* button.onclick = () => reportBug({ message: input.value, email: userEmail });
|
|
724
|
+
*/
|
|
725
|
+
export async function reportBug(report) {
|
|
726
|
+
const s = activeBugSession;
|
|
727
|
+
if (!s) {
|
|
728
|
+
throw new Error("reportBug() called before instrumentWeb(); call instrumentWeb() first");
|
|
729
|
+
}
|
|
730
|
+
const message = typeof report.message === "string" ? report.message.trim() : "";
|
|
731
|
+
if (!message)
|
|
732
|
+
throw new Error("reportBug() requires a non-empty message");
|
|
733
|
+
const payload = buildBugReportPayload(s, { ...report, message });
|
|
734
|
+
// 1. POST the structured report via the ORIGINAL fetch (never the patched one).
|
|
735
|
+
await s
|
|
736
|
+
.fetchImpl(`${s.endpoint}/v1/bug-reports`, {
|
|
737
|
+
method: "POST",
|
|
738
|
+
headers: { authorization: `Bearer ${s.apiKey}`, "content-type": "application/json" },
|
|
739
|
+
body: JSON.stringify(payload),
|
|
740
|
+
})
|
|
741
|
+
.catch(() => { });
|
|
742
|
+
// 2. Emit one OTLP log so the report also appears on the telemetry timeline.
|
|
743
|
+
const active = s.activeTrace.get();
|
|
744
|
+
s.logsExporter.add({
|
|
745
|
+
timeUnixMs: Date.now(),
|
|
746
|
+
severityText: "INFO",
|
|
747
|
+
body: message,
|
|
748
|
+
attributes: {
|
|
749
|
+
"event.name": "user.bug_report",
|
|
750
|
+
"url.full": typeof location !== "undefined" ? location.href : "",
|
|
751
|
+
session_id: s.sessionId,
|
|
752
|
+
...(report.email ? { "user.email": report.email } : {}),
|
|
753
|
+
},
|
|
754
|
+
traceId: active?.traceId,
|
|
755
|
+
spanId: active?.spanId,
|
|
756
|
+
});
|
|
757
|
+
await s.logsExporter.flush().catch(() => { });
|
|
758
|
+
}
|
package/dist/workers.d.ts
CHANGED
|
@@ -134,6 +134,18 @@ export interface WorkersConfig {
|
|
|
134
134
|
/** Defaults to env.HEYSTACK_API_KEY at request time if omitted. */
|
|
135
135
|
apiKey?: string;
|
|
136
136
|
endpoint?: string;
|
|
137
|
+
/**
|
|
138
|
+
* Release identifier for this deploy → `service.version` resource attribute.
|
|
139
|
+
* Powers release health + the suspect-release view in the console. See
|
|
140
|
+
* {@link HeystackOptions.version}.
|
|
141
|
+
*/
|
|
142
|
+
version?: string;
|
|
143
|
+
/**
|
|
144
|
+
* Commit SHA for this deploy → `service.build` resource attribute. Powers
|
|
145
|
+
* suspect-commit attribution (deep-links to the commit when a repo URL is set
|
|
146
|
+
* in the console). See {@link HeystackOptions.build}.
|
|
147
|
+
*/
|
|
148
|
+
build?: string;
|
|
137
149
|
/**
|
|
138
150
|
* Optional override to keep the isolate alive until each export `fetch`
|
|
139
151
|
* completes. When provided this takes priority over the auto-detected
|
package/dist/workers.js
CHANGED
|
@@ -13,7 +13,7 @@ import { ROOT_CONTEXT } from "@opentelemetry/api";
|
|
|
13
13
|
import { Resource } from "@opentelemetry/resources";
|
|
14
14
|
import { BasicTracerProvider, SimpleSpanProcessor, } from "@opentelemetry/sdk-trace-base";
|
|
15
15
|
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
|
16
|
-
import { buildExporterConfig, DEFAULT_ENDPOINT } from "./core.js";
|
|
16
|
+
import { buildExporterConfig, DEFAULT_ENDPOINT, releaseResourceAttributes, } from "./core.js";
|
|
17
17
|
import { isSelfSpanAttrs, safeHostname } from "./self-span.js";
|
|
18
18
|
import { detectLLMProvider, llmRequestAttrs, llmResponseAttrs, llmContentAttrs } from "./llm-enrich.js";
|
|
19
19
|
import { instrumentEnv } from "./workers-bindings.js";
|
|
@@ -576,7 +576,12 @@ export class HeystackSpanExporter {
|
|
|
576
576
|
export function createTracerProvider(config) {
|
|
577
577
|
const exporter = new HeystackSpanExporter(config);
|
|
578
578
|
const provider = new BasicTracerProvider({
|
|
579
|
-
|
|
579
|
+
// service.name is always present; service.version/service.build are added
|
|
580
|
+
// only when the caller supplied `version`/`build` (release attribution).
|
|
581
|
+
resource: new Resource({
|
|
582
|
+
[ATTR_SERVICE_NAME]: config.service,
|
|
583
|
+
...releaseResourceAttributes(config),
|
|
584
|
+
}),
|
|
580
585
|
spanProcessors: [new SimpleSpanProcessor(exporter)],
|
|
581
586
|
sampler: makeSampler(config.sampling),
|
|
582
587
|
});
|
|
@@ -781,6 +786,8 @@ export function instrument(handler, config) {
|
|
|
781
786
|
apiKey,
|
|
782
787
|
service: config.service,
|
|
783
788
|
endpoint: config.endpoint,
|
|
789
|
+
version: config.version,
|
|
790
|
+
build: config.build,
|
|
784
791
|
waitUntil: config.waitUntil,
|
|
785
792
|
sampling: config.sampling,
|
|
786
793
|
ai: config.ai,
|