@heystack/otel 0.18.0 → 0.19.1
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 +151 -27
- package/dist/core.d.ts +1 -0
- package/dist/core.js +4 -0
- package/dist/hash.d.ts +19 -0
- package/dist/hash.js +47 -0
- package/dist/next.d.ts +2 -1
- package/dist/next.js +5 -0
- package/dist/node.d.ts +14 -2
- package/dist/node.js +49 -4
- package/dist/redact.d.ts +57 -0
- package/dist/redact.js +190 -0
- package/dist/web.d.ts +42 -0
- package/dist/web.js +91 -24
- package/dist/workers-bindings.d.ts +39 -2
- package/dist/workers-bindings.js +388 -78
- package/dist/workers-coverage.d.ts +11 -0
- package/dist/workers-coverage.js +22 -0
- package/dist/workers-native.d.ts +66 -0
- package/dist/workers-native.js +147 -0
- package/dist/workers-propagation.d.ts +24 -0
- package/dist/workers-propagation.js +72 -0
- package/dist/workers-routes.d.ts +11 -0
- package/dist/workers-routes.js +45 -0
- package/dist/workers-sampler.d.ts +10 -1
- package/dist/workers-sampler.js +7 -3
- package/dist/workers.d.ts +108 -19
- package/dist/workers.js +639 -190
- package/package.json +9 -4
package/README.md
CHANGED
|
@@ -57,6 +57,27 @@ HEYSTACK_SERVICE_BUILD=$(git rev-parse HEAD)
|
|
|
57
57
|
|
|
58
58
|
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.
|
|
59
59
|
|
|
60
|
+
## Privacy: URL redaction & client IP
|
|
61
|
+
|
|
62
|
+
Every entry scrubs URLs **before** they leave your process, so API keys and tokens that ride in query strings never reach Heystack:
|
|
63
|
+
|
|
64
|
+
- **Userinfo is stripped** — `https://user:pass@host/x` is recorded as `https://host/x`.
|
|
65
|
+
- **Sensitive query and fragment parameter values become `REDACTED`** — `?key=AIza…` (Gemini), `?apikey=…` (Supabase), `?code=…` (OAuth), `#access_token=…&refresh_token=…` (implicit flow), pre-signed URL signatures (`X-Amz-Signature`, `X-Amz-Credential`, `X-Amz-Security-Token`, `X-Goog-Signature`), and more. Matching is case-insensitive. The built-in list: `key`, `api_key`, `apikey`, `api-key`, `token`, `access_token`, `refresh_token`, `id_token`, `auth_token`, `api_token`, `oauth_token`, `oauth_signature`, `code`, `sig`, `signature`, `password`, `passwd`, `pass`, `secret`, `client_secret`, `auth`, `authorization`, `session`, `sessionid`, `session_id`, `jwt`, `x-amz-signature`, `x-amz-credential`, `x-amz-security-token`, `x-goog-signature`, `x-goog-credential`. OAuth `state` is deliberately kept (it's a CSRF nonce, handy when debugging a broken login redirect).
|
|
66
|
+
- Everything else (scheme, host, path, other parameters, plain `#anchors`) is kept exactly as written. Only the **recorded** URL changes — your app's real requests are untouched.
|
|
67
|
+
|
|
68
|
+
Where it applies: `url.full`, `http.url`, `http.target`, `url.original`, `url.query` and `url.fragment` on every exported span (Node's http/undici/Next.js spans and Workers' own SERVER/CLIENT spans, plus any manual span), and in the browser on fetch spans, the page URL attached to errors, console logs and bug reports, and the replay start URL / page-URL events.
|
|
69
|
+
|
|
70
|
+
Options (all entries — `initHeystack`, `registerHeystack`, `instrument` / `initHeystackWorkers`, `instrumentWeb`):
|
|
71
|
+
|
|
72
|
+
| Option | Default | Effect |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| `redactQueryParams` | built-in list | `string[]` **adds** parameter names to the built-in list (e.g. `["invite", "otp"]`). `false` turns parameter redaction off (userinfo is still stripped). |
|
|
75
|
+
| `captureClientIp` | `false` | Server entries only. Record the end user's IP address. **Off by default** — an IP is personal data in most jurisdictions. On `/workers` this gates `client.address` (from `CF-Connecting-IP`); on `/node` it keeps `client.address` / `http.client_ip` and, on SERVER spans, `net.peer.ip` / `network.peer.address` / `net.sock.peer.addr`. |
|
|
76
|
+
|
|
77
|
+
Use the same rules for URLs you log yourself: `import { redactUrl } from "@heystack/otel"` (pure, works in every runtime).
|
|
78
|
+
|
|
79
|
+
> **Behaviour change (0.19.0):** URL redaction is now on by default, and `client.address` is no longer recorded unless `captureClientIp: true`. On `/workers`, `geo.region` / `geo.city` / `geo.asn` are now opt-in (`captureGeo: "full"`); `geo.country` is still recorded by default.
|
|
80
|
+
|
|
60
81
|
## Node / Express / etc.
|
|
61
82
|
|
|
62
83
|
At the very top of your app's entry file:
|
|
@@ -129,7 +150,7 @@ head_sampling_rate = 1
|
|
|
129
150
|
persist = false
|
|
130
151
|
```
|
|
131
152
|
|
|
132
|
-
Cloudflare creates the invocation and supported platform/binding spans and exports trace-correlated logs. `instrument()` detects `ctx.tracing` and adds Heystack business/identity attributes to that same tree. In this mode it creates no OTel provider, makes no Heystack export request, does not
|
|
153
|
+
Cloudflare creates the invocation and supported platform/binding spans and exports trace-correlated logs. `instrument()` detects `ctx.tracing` and adds Heystack business/identity attributes to that same tree. In this mode it creates no OTel provider, makes no Heystack export request, only enriches the bindings Cloudflare does not trace itself (see below), and does not require `HEYSTACK_API_KEY` in the Worker. The only `fetch` patch it installs is a **lean, LLM-only** one (0.14+): outbound calls to a detected LLM provider or a configured AI gateway get a native `<operation> <provider>` span (e.g. `chat mistral`) with `gen_ai.*` attributes — every other subrequest passes through untouched, since Cloudflare already records it. See [AI / LLM observability](#ai--llm-observability).
|
|
133
154
|
|
|
134
155
|
```ts
|
|
135
156
|
import { instrument, withCloudflareSpan } from "@heystack/otel/workers";
|
|
@@ -155,6 +176,39 @@ For a Python Worker, configure the same `observability.traces` and `observabilit
|
|
|
155
176
|
|
|
156
177
|
Cloudflare native tracing automatically covers handler invocations, fetch subrequests, and supported platform operations such as KV, R2, and Durable Objects. The direct JavaScript binding proxies below are a compatibility and enrichment layer; they are not required when native tracing is active.
|
|
157
178
|
|
|
179
|
+
#### Native binding enrichment (what and why)
|
|
180
|
+
|
|
181
|
+
Cloudflare already spans D1, KV, R2, Durable Objects, Queues, rate limiting, `fetch` and service bindings, so in native mode the SDK does **not** wrap them (no duplicate spans). It enriches only what Cloudflare leaves out, in every handler kind (`fetch`, `queue`, `scheduled`, `email`):
|
|
182
|
+
|
|
183
|
+
| Binding | Native span | Notes |
|
|
184
|
+
| --- | --- | --- |
|
|
185
|
+
| Workers AI | `ai_run` | `gen_ai.provider.name=cloudflare`, model, token usage. Prompts and completions are never recorded. |
|
|
186
|
+
| Vectorize | `vectorize_<op>` | `query`, `insert`, `upsert`, `getByIds`, `deleteByIds`, `describe`. |
|
|
187
|
+
| Workflows | `workflow_<op>` | `create`, `createBatch`, `get`, `status`, `pause`, `resume`, `terminate`, `restart`. |
|
|
188
|
+
| Analytics Engine | `analytics_engine_writeDataPoint` | |
|
|
189
|
+
| Hyperdrive | `hyperdrive_query` | Wrap your Postgres/MySQL client with `wrapSqlClient(client, { name: "HYPERDRIVE", dbSystem: "postgresql" })`. |
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
import { wrapSqlClient } from "@heystack/otel/workers";
|
|
193
|
+
|
|
194
|
+
// postgres.js (tagged templates, .unsafe())
|
|
195
|
+
const sql = wrapSqlClient(postgres(env.HYPERDRIVE.connectionString), { name: "HYPERDRIVE", dbSystem: "postgresql" });
|
|
196
|
+
const rows = await sql`select 1`;
|
|
197
|
+
|
|
198
|
+
// pg
|
|
199
|
+
const client = wrapSqlClient(new Client({ connectionString: env.HYPERDRIVE.connectionString }), { name: "HYPERDRIVE", dbSystem: "postgresql" });
|
|
200
|
+
await client.connect();
|
|
201
|
+
await client.query("select 1");
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The original return value is passed through untouched; the span ends when it settles.
|
|
205
|
+
|
|
206
|
+
Native mode now auto-creates `ai_run`; if you wrap `env.AI.run` yourself with `withSpan`, set `nativeEnrichment: { ai: false }` to avoid a duplicate. Native children of Analytics Engine and workflow-instance operations do not nest under their span.
|
|
207
|
+
|
|
208
|
+
Tune with `nativeEnrichment` (per-kind, e.g. `{ ai: false }` when you create your own `ai_run` span with `withSpan`, or `{ d1: true }` to force a covered kind). Setting `instrumentBindings` explicitly wins over this gate in native mode and wraps exactly what you list (which can duplicate Cloudflare's own spans). `NATIVELY_COVERED` and `shouldEnrichNatively` are exported for inspection.
|
|
209
|
+
|
|
210
|
+
Durable Objects have no `ctx.tracing`. Pass Cloudflare's tracer explicitly with the `tracing` option (`instrument(handler, { service, tracing })`); when set, it takes precedence over `ctx.tracing`. Custom spans use Cloudflare's `startActiveSpan` when available (falling back to `enterSpan`) and record exceptions natively. Cloudflare's custom span API exposes no trace or span ids, so Heystack never invents them.
|
|
211
|
+
|
|
158
212
|
The wrapper also enters native spans for default-export queue and scheduled handlers. `withCloudflareSpan(ctx, ...)` remains native across arbitrary `await` boundaries and does not require `nodejs_compat`. `withSpan(...)` routes to the native tracer whenever its active context is available.
|
|
159
213
|
|
|
160
214
|
### Cloudflare Agents SDK
|
|
@@ -221,11 +275,20 @@ Set the key as a secret: `wrangler secret put HEYSTACK_API_KEY`, and set `native
|
|
|
221
275
|
| `getUser` | `(req: Request) => { id?, session?, requestId? } \| undefined` | Called per request. `id` → `enduser.id`, `session` → `session.id`, `requestId` → `http.request.id` (falls back to the `cf-ray` header). |
|
|
222
276
|
| `instrumentBindings` | `boolean \| string[]` | Direct mode only. `true` = child spans for all detected supported bindings; `string[]` = only named bindings. Default `false`. See the operation matrix below. |
|
|
223
277
|
| `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. |
|
|
224
|
-
| `
|
|
278
|
+
| `nativeEnrichment` | `Partial<Record<BindingKind, boolean>>` | Native mode: per-kind override of which bindings get enriched spans (default: only kinds Cloudflare does not trace). Ignored when `instrumentBindings` is set. |
|
|
279
|
+
| `ai` | `{ captureContent?: boolean; redact?: (text: string) => string; maxContentChars?: number; gateways?: Array<{ hostSuffix: string; pathPattern: string }>; gatewayExport?: boolean }` | LLM/gen_ai capture for outbound calls to known providers (works under native tracing and the direct exporter). `gateways` declares AI gateways/proxies you call instead of the provider host (e.g. Wafer). See [AI / LLM observability](#ai--llm-observability) below. |
|
|
280
|
+
| `routes` | `string[]` | Path templates for `http.route` (first match wins), e.g. `["/users/:id", "/files/*"]` (trailing `*` matches the rest). Native mode no longer reports the raw path as `http.route`; the template is set on the `heystack.request` span and, when the runtime exposes the active invocation span (`getActiveSpan()`), on that span too; Service Health reads the invocation span, so where it is not exposed an unlabelled root is templated server-side from its path. With neither `routes` nor `routeTemplate`, `http.route` is omitted and Heystack templates the path server-side. |
|
|
281
|
+
| `routeTemplate` | `(req: Request) => string \| undefined` | Compute the `http.route` template per request; wins over `routes`. |
|
|
282
|
+
| `propagateTargets` | `Array<string \| RegExp>` | Native mode only: hostnames (exact string or RegExp) that receive `traceparent` on outbound fetches. Default: a per-invocation `traceparent` is sent to every outbound host except the Heystack ingest endpoint; set this to an allow-list to limit what you disclose. `[]` disables propagation. |
|
|
283
|
+
| `ai.gatewayExport` | `boolean` | Set `true` only if your Cloudflare AI Gateway exports its own OpenTelemetry spans to Heystack: the SDK LLM span is then marked `heystack.ai.delegated` so the call is counted once. Default `false` (the SDK span is the counted one), so an AI call is not lost when the gateway export is not configured; the trade-off is a double count if you also export from the gateway without setting this. `cf-aig-otel-trace-id` / `cf-aig-otel-parent-span-id` headers are sent either way, to `gateway.ai.cloudflare.com` only (custom `ai.gateways` entries do not get them yet); the span is marked delegated only when the SDK actually sent those headers (not when `propagateTargets` excludes the host or you supplied your own `cf-aig-otel-trace-id`). |
|
|
225
284
|
| `waitUntil` | `(p: Promise<unknown>) => void` | Override the isolate keep-alive hook; defaults to the auto-detected `ctx.waitUntil`. |
|
|
226
285
|
| `version` | `string?` | Release identifier → `service.version` resource attribute. Powers release health + suspect release. See [Release & commit attribution](#release--commit-attribution-version--build). |
|
|
227
286
|
| `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). |
|
|
228
287
|
| `endpoint` | `string?` | Override the ingest endpoint (advanced). |
|
|
288
|
+
| `redactQueryParams` | `string[] \| false` | Extra query/fragment parameter names to redact in recorded URLs (added to the built-in list), or `false` to disable. See [Privacy](#privacy-url-redaction--client-ip). |
|
|
289
|
+
| `captureClientIp` | `boolean?` | Record `client.address` from `CF-Connecting-IP`. Default **`false`**. |
|
|
290
|
+
| `captureGeo` | `"country" \| "full" \| false` | Cloudflare geo on the SERVER span. Default `"country"` (only `geo.country`); `"full"` adds `geo.region`, `geo.city`, `geo.asn`; `false` records none. |
|
|
291
|
+
| `trustInboundSampling` | `boolean?` | Default `true`: an inbound `traceparent` sampled flag decides keep/drop. Set `false` on a Worker that takes traffic from callers you don't control — see [Head sampling](#head-sampling). |
|
|
229
292
|
|
|
230
293
|
### Head sampling
|
|
231
294
|
|
|
@@ -244,6 +307,16 @@ The sampler is **deterministic by trace ID**: the same trace always makes the sa
|
|
|
244
307
|
|
|
245
308
|
The `rate` governs **fresh root traces only** (no inbound `traceparent`, or `traceparent` with `sampled=0`). A request arriving with a sampled inbound `traceparent` (`sampled=1`) is always recorded — the parent's decision is respected, so a distributed trace is never split mid-flight by the child's sample rate.
|
|
246
309
|
|
|
310
|
+
**Untrusted callers (`trustInboundSampling: false`).** Anyone can send `traceparent: 00-…-01`, so on a public endpoint a client can force its requests past your head-sampling rate (or hide them with `-00`). Set `trustInboundSampling: false` to make **this Worker's** rate decide for inbound traces too: the inbound trace id is still continued (browser→Worker correlation keeps working), but keep/drop is the deterministic hash of that trace id against your `rate` — so services sampling at the same rate still agree. The default stays `true` for backward compatibility and because coordinated browser→backend sampling depends on it; ingest-side sampling (which ignores the flag) still bounds what is stored.
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
export default instrument(worker, {
|
|
314
|
+
service: "public-api",
|
|
315
|
+
sampling: { rate: 0.1 },
|
|
316
|
+
trustInboundSampling: false, // a client's `-01` can't force recording
|
|
317
|
+
});
|
|
318
|
+
```
|
|
319
|
+
|
|
247
320
|
### Remote sampling
|
|
248
321
|
|
|
249
322
|
`sampling: { remote: true }` lets Heystack control the rate centrally — change it from the dashboard without redeploying:
|
|
@@ -261,8 +334,8 @@ On startup the worker fetches its configured rate from the Heystack config endpo
|
|
|
261
334
|
|
|
262
335
|
`instrument()` traces the following automatically, with no additional config:
|
|
263
336
|
|
|
264
|
-
- **Incoming requests (`fetch`)** — a SERVER span per request, carrying `http.request.method`, `url.full
|
|
265
|
-
- **Outbound `fetch`** — each outbound subrequest while a request span is active gets a CLIENT child span (`http.request.method`, `url.full
|
|
337
|
+
- **Incoming requests (`fetch`)** — a SERVER span per request, carrying `http.request.method`, `url.full` (redacted — see [Privacy](#privacy-url-redaction--client-ip)), `url.path`, `server.address`, `http.response.status_code`, and `enduser.id`/country attributes (plus opt-in `client.address` / fine geo — see [Client enrichment](#client-enrichment) below). An inbound W3C `traceparent` header is continued so the client and server share one trace; a `traceparent` response header (+ `Access-Control-Expose-Headers: traceparent`) is set so downstream clients can read it.
|
|
338
|
+
- **Outbound `fetch`** — each outbound subrequest while a request span is active gets a CLIENT child span (`http.request.method`, `url.full` (redacted), `server.address`, `http.response.status_code`). A W3C `traceparent` header is injected into the subrequest so a downstream Heystack-instrumented service continues the same trace (distributed tracing across services). The exporter's own ingest POST is never traced.
|
|
266
339
|
- **Queue consumers (`queue`)** — a CONSUMER span per batch, with `messaging.destination.name` (queue name) and `messaging.batch.message_count`.
|
|
267
340
|
- **Scheduled handlers (`scheduled`)** — an INTERNAL span per invocation, with `controller.cron`.
|
|
268
341
|
- **Binding calls** (direct mode, when `instrumentBindings` is set) — child spans for the stable operations listed below. Service binding `.fetch` calls receive a W3C `traceparent` header so another instrumented Worker can continue the trace.
|
|
@@ -284,15 +357,16 @@ Some bindings intentionally remain outside generic direct-mode proxying. Durable
|
|
|
284
357
|
|
|
285
358
|
### Client enrichment
|
|
286
359
|
|
|
287
|
-
These attributes are set
|
|
360
|
+
These attributes are set on every SERVER span from request metadata:
|
|
288
361
|
|
|
289
|
-
| Attribute | Source |
|
|
290
|
-
| --- | --- |
|
|
291
|
-
| `enduser.id` | `getUser(req).id` |
|
|
292
|
-
| `session.id` | `getUser(req).session` |
|
|
293
|
-
| `http.request.id` | `getUser(req).requestId` or `cf-ray` header |
|
|
294
|
-
| `
|
|
295
|
-
| `geo.
|
|
362
|
+
| Attribute | Source | Default |
|
|
363
|
+
| --- | --- | --- |
|
|
364
|
+
| `enduser.id` | `getUser(req).id` | on (when `getUser` returns it) |
|
|
365
|
+
| `session.id` | `getUser(req).session` | on (when `getUser` returns it) |
|
|
366
|
+
| `http.request.id` | `getUser(req).requestId` or `cf-ray` header | on |
|
|
367
|
+
| `geo.country` | Cloudflare `req.cf.country` | on (`captureGeo: false` to disable) |
|
|
368
|
+
| `geo.region`, `geo.city`, `geo.asn` | Cloudflare `req.cf` object | **off** — `captureGeo: "full"` |
|
|
369
|
+
| `client.address` | `CF-Connecting-IP` header | **off** — `captureClientIp: true` |
|
|
296
370
|
|
|
297
371
|
### AI / LLM observability
|
|
298
372
|
|
|
@@ -406,7 +480,7 @@ The automatic `gen_ai.*` enrichment hooks `fetch`. Anything that doesn't go thro
|
|
|
406
480
|
import { instrument, withSpan, genAiRequestAttrs, genAiResponseAttrs } from "@heystack/otel/workers";
|
|
407
481
|
|
|
408
482
|
async function chat(env: Env, prompt: string) {
|
|
409
|
-
const model = "@cf/meta/llama-3.1-8b-instruct";
|
|
483
|
+
const model = "@cf/meta/llama-3.1-8b-instruct-fast";
|
|
410
484
|
return withSpan(
|
|
411
485
|
"chat cloudflare",
|
|
412
486
|
{ ...genAiRequestAttrs("cloudflare", { model, max_tokens: 256 }), "gen_ai.operation.name": "chat" },
|
|
@@ -421,7 +495,7 @@ async function chat(env: Env, prompt: string) {
|
|
|
421
495
|
}
|
|
422
496
|
```
|
|
423
497
|
|
|
424
|
-
`genAiRequestAttrs` sets `gen_ai.system` + `gen_ai.request.{model,max_tokens,temperature}`; `genAiResponseAttrs` understands OpenAI/Mistral, Anthropic, Google and Workers AI response shapes and returns `gen_ai.response.{model,id,finish_reason}` + `gen_ai.usage.*`. Neither ever throws or captures prompt/completion text. (If `instrumentBindings` is on under the direct exporter, `env.AI.run` is already traced automatically —
|
|
498
|
+
`genAiRequestAttrs` sets `gen_ai.system` + `gen_ai.request.{model,max_tokens,temperature}`; `genAiResponseAttrs` understands OpenAI/Mistral, Anthropic, Google and Workers AI response shapes and returns `gen_ai.response.{model,id,finish_reason}` + `gen_ai.usage.*`. Neither ever throws or captures prompt/completion text. (If `instrumentBindings` is on under the direct exporter, `env.AI.run` is already traced automatically — under native tracing `env.AI.run` is also recorded automatically as `ai_run`, so a manual span around it needs `nativeEnrichment: { ai: false }` or the call is counted twice. Use the manual form for direct mode without `instrumentBindings` and for other call paths.)
|
|
425
499
|
|
|
426
500
|
### Why a ContextManager
|
|
427
501
|
|
|
@@ -475,14 +549,14 @@ For a browser frontend (any SPA / web app), `instrumentWeb` records **session re
|
|
|
475
549
|
|
|
476
550
|
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).
|
|
477
551
|
|
|
552
|
+
**Use a Browser key (`hs_web_…`), never your server key.** Anything in a client bundle is public. Mint a **Browser key** in the console (app → **Settings → Ingest keys → Browser key**, or `heystack_create_key` with `class: "web"`). It is write-only: ingest accepts it only on the paths this entry calls (`/v1/traces`, `/v1/logs`, `/v1/replay`, `/v1/replay/config`, `/v1/bug-reports`) and rejects it on every read/management API, `/v1/metrics`, `/v1/sampling/config` and dSYM upload. **Never put a `sk_live_` server key in browser code or in a `VITE_` / `NEXT_PUBLIC_` variable** — if one already shipped, switch to a Browser key and revoke + rotate the server key.
|
|
553
|
+
|
|
478
554
|
```ts
|
|
479
555
|
import { instrumentWeb } from "@heystack/otel/web";
|
|
480
556
|
|
|
481
557
|
const stop = await instrumentWeb({
|
|
482
|
-
// A
|
|
483
|
-
|
|
484
|
-
// rotate independently of your server-side key. NOT your server secret.
|
|
485
|
-
apiKey: import.meta.env.VITE_HEYSTACK_API_KEY, // Vite; Next.js: process.env.NEXT_PUBLIC_HEYSTACK_API_KEY
|
|
558
|
+
// A public, write-only Browser key (hs_web_…) — safe in the client bundle.
|
|
559
|
+
apiKey: import.meta.env.VITE_HEYSTACK_WEB_KEY, // Vite; Next.js: process.env.NEXT_PUBLIC_HEYSTACK_WEB_KEY
|
|
486
560
|
service: "my-web-app",
|
|
487
561
|
});
|
|
488
562
|
|
|
@@ -498,7 +572,7 @@ stop();
|
|
|
498
572
|
|
|
499
573
|
| Option | Type | Notes |
|
|
500
574
|
| --- | --- | --- |
|
|
501
|
-
| `apiKey` | `string` | **Required.** A Heystack
|
|
575
|
+
| `apiKey` | `string` | **Required.** A Heystack **Browser key** (`hs_web_…`), **exposed to the browser** (public env var). Write-only and browser-paths-only. Never a `sk_live_` server key. |
|
|
502
576
|
| `service` | `string` | **Required.** The OTel service name (matches the app's service in the console). |
|
|
503
577
|
| `userId` | `string?` | Optional app-supplied identifier stamped on the session. |
|
|
504
578
|
| `endpoint` | `string?` | Optional ingest endpoint override (defaults to the Heystack ingest endpoint). |
|
|
@@ -519,7 +593,7 @@ By default `/web` only records session replay. Set `tracing: true` to also trace
|
|
|
519
593
|
|
|
520
594
|
```ts
|
|
521
595
|
await instrumentWeb({
|
|
522
|
-
apiKey: import.meta.env.
|
|
596
|
+
apiKey: import.meta.env.VITE_HEYSTACK_WEB_KEY,
|
|
523
597
|
service: "my-web-app",
|
|
524
598
|
tracing: true,
|
|
525
599
|
traceSampleRate: 0.25, // sample 25% of requests — tune for cost
|
|
@@ -539,7 +613,7 @@ Otherwise it's **cost-aware and safe by design**: off unless you opt in; head-sa
|
|
|
539
613
|
|
|
540
614
|
```ts
|
|
541
615
|
await instrumentWeb({
|
|
542
|
-
apiKey: import.meta.env.
|
|
616
|
+
apiKey: import.meta.env.VITE_HEYSTACK_WEB_KEY,
|
|
543
617
|
service: "my-web-app",
|
|
544
618
|
version: "1.4.2", // → service.version (release attribution)
|
|
545
619
|
build: "abc1234", // → service.build (commit)
|
|
@@ -561,7 +635,7 @@ Call it any time after `instrumentWeb()` has run (it throws if the SDK isn't ini
|
|
|
561
635
|
```ts
|
|
562
636
|
import { instrumentWeb, reportBug } from "@heystack/otel/web";
|
|
563
637
|
|
|
564
|
-
await instrumentWeb({ apiKey: import.meta.env.
|
|
638
|
+
await instrumentWeb({ apiKey: import.meta.env.VITE_HEYSTACK_WEB_KEY, service: "my-web-app" });
|
|
565
639
|
|
|
566
640
|
// A minimal report button (wire this to whatever UI you like):
|
|
567
641
|
const btn = document.querySelector("#report-bug")!;
|
|
@@ -589,13 +663,56 @@ By default **sampling and masking are controlled from the Heystack console** —
|
|
|
589
663
|
|
|
590
664
|
### Privacy / masking (rrweb)
|
|
591
665
|
|
|
592
|
-
Masking
|
|
666
|
+
Masking happens in the browser, before anything is uploaded. There are two modes (set under **Settings → Session replay**); **strict is the default**, and any missing or unknown mode is treated as strict:
|
|
667
|
+
|
|
668
|
+
- **Strict** — **all page text and all input values are masked** (each character becomes `*`, so layout is preserved). Opt specific elements back in with `data-hs-unmask`.
|
|
669
|
+
- **Relaxed** — page text and input values are recorded, except text inside `data-hs-mask` and password inputs.
|
|
593
670
|
|
|
594
671
|
| Attribute | Effect |
|
|
595
672
|
| --- | --- |
|
|
596
|
-
| `data-hs-
|
|
597
|
-
| `data-hs-
|
|
598
|
-
| `data-hs-
|
|
673
|
+
| `data-hs-unmask` | Strict mode: records this element and its descendants in cleartext (text + non-password input values). |
|
|
674
|
+
| `data-hs-mask` | Masks this element's text. Inside a `data-hs-unmask` region it re-masks (the nearest marker wins). |
|
|
675
|
+
| `data-hs-block` | Blocks a region entirely (recorded as an empty placeholder box). Both modes. |
|
|
676
|
+
|
|
677
|
+
**Password inputs are always masked**, in both modes, even inside `data-hs-unmask`.
|
|
678
|
+
|
|
679
|
+
Limitations: masking covers text and form values. Element **attributes** (`title`, `alt`, `placeholder`, `aria-label`, link `href`s) and images are recorded as-is — wrap regions whose attributes or images are sensitive in `data-hs-block`. The page URL in replay metadata is redacted with the same rules as every other recorded URL; pass `redactQueryParams` to `instrumentWeb` to add your own parameter names (see [Privacy](#privacy-url-redaction--client-ip)).
|
|
680
|
+
|
|
681
|
+
## Cloudflare coverage
|
|
682
|
+
|
|
683
|
+
<!-- cf-coverage:start -->
|
|
684
|
+
| Signal | Native mode | Direct mode | Notes |
|
|
685
|
+
| --- | --- | --- | --- |
|
|
686
|
+
| Worker fetch handler | Cloudflare (expected; not yet verified live) | Heystack SDK | |
|
|
687
|
+
| Cron (scheduled) handler | Heystack SDK | Heystack SDK | Native mode: the SDK adds its own span; Cloudflare's scheduled span shape is not in the documented list. |
|
|
688
|
+
| Queue consumer handler | Cloudflare (expected; not yet verified live) | Heystack SDK | |
|
|
689
|
+
| Email handler | Cloudflare (expected; not yet verified live) | Heystack SDK | Addresses are hashed; direct mode records only domains. |
|
|
690
|
+
| Tail handler | Heystack SDK | Heystack SDK | |
|
|
691
|
+
| Durable Object alarm handler | Not covered | Heystack SDK | Class wrappers need the SDK provider; native mode has no alarm handler span. |
|
|
692
|
+
| Durable Object WebSocket handlers | Not covered | Heystack SDK | |
|
|
693
|
+
| WorkerEntrypoint RPC method | Cloudflare (expected; not yet verified live) | Heystack SDK | Native mode: the SDK adds no RPC span (Cloudflare owns jsrpc). Span name not yet verified against a live deployment. |
|
|
694
|
+
| Workflow run and steps | Not covered | Heystack SDK | Via wrapWorkflowEntrypointClass; no native workflow handler span is documented. |
|
|
695
|
+
| D1 | Cloudflare (expected; not yet verified live) | Heystack SDK | |
|
|
696
|
+
| KV | Cloudflare (expected; not yet verified live) | Heystack SDK | Keys are hashed. |
|
|
697
|
+
| R2 | Cloudflare (expected; not yet verified live) | Heystack SDK | |
|
|
698
|
+
| Durable Object storage (KV API) | Cloudflare (expected; not yet verified live) | Heystack SDK | Native span name durable_object_storage_get is not yet verified against a live deployment. |
|
|
699
|
+
| Durable Object storage (SQL API) | Cloudflare (expected; not yet verified live) | Heystack SDK | |
|
|
700
|
+
| Durable Object alarms API | Cloudflare (expected; not yet verified live) | Heystack SDK | |
|
|
701
|
+
| Queue producer | Cloudflare (expected; not yet verified live) | Heystack SDK | |
|
|
702
|
+
| Workers AI | Heystack SDK | Heystack SDK | Native mode: SDK enrichment (Cloudflare does not span it). |
|
|
703
|
+
| Vectorize | Heystack SDK | Heystack SDK | Native mode: SDK enrichment. |
|
|
704
|
+
| Workflows binding | Heystack SDK | Heystack SDK | Native mode: SDK enrichment. |
|
|
705
|
+
| Failing binding call | Heystack SDK | Heystack SDK | A throwing binding marks its span as an error and the error still reaches the app. |
|
|
706
|
+
| Hyperdrive (SQL client) | Heystack SDK | Heystack SDK | Needs wrapSqlClient around the database client. |
|
|
707
|
+
| Analytics Engine | Heystack SDK | Heystack SDK | Native mode: SDK enrichment. |
|
|
708
|
+
| Rate limit | Cloudflare (expected; not yet verified live) | Heystack SDK | |
|
|
709
|
+
| Service binding | Cloudflare (expected; not yet verified live) | Heystack SDK | Native mode: Cloudflare propagates context between Workers itself. Span name not yet verified against a live deployment. |
|
|
710
|
+
| LLM provider call (OpenAI, Anthropic, ...) | Heystack SDK | Heystack SDK | |
|
|
711
|
+
| Cloudflare AI Gateway call | Heystack SDK | Heystack SDK | Counted once; pass ai.gatewayExport to defer to the gateway's own export. |
|
|
712
|
+
| Inbound traceparent | Heystack SDK | Heystack SDK | Native mode records a parent link; it cannot adopt the caller's trace id. |
|
|
713
|
+
| Outbound traceparent | Heystack SDK | Heystack SDK | Native mode sends a synthetic id linked to the request; Cloudflare spans carry no ids. |
|
|
714
|
+
| Pages Functions | Direct mode only | Heystack SDK | Use the SDK's direct export in Pages Functions. |
|
|
715
|
+
<!-- cf-coverage:end -->
|
|
599
716
|
|
|
600
717
|
## Flushing
|
|
601
718
|
|
|
@@ -615,10 +732,12 @@ As belt-and-suspenders the exporter also drops any span whose HTTP target points
|
|
|
615
732
|
|
|
616
733
|
## Migration / versioning
|
|
617
734
|
|
|
735
|
+
- **`0.19.1`** — **Workers fixes found by the live Cloudflare run.** (1) A Durable Object class that extends `DurableObject` from `cloudflare:workers` and is wrapped with `wrapDurableObjectClass` failed to construct on Cloudflare (`constructor parameter 1 is not of type 'DurableObjectState'`): the base class now receives the genuine state, and the traced view replaces it on the instance, so `this.ctx.storage` stays traced. (2) With `instrumentBindings`, a service binding to a `WorkerEntrypoint` was mistaken for a D1 binding (its RPC stub answers every property name), so every RPC call through it failed (`The RPC receiver does not implement the method "bind"`); RPC service bindings are now detected first and their stubs pass through untouched, including nested property chains. (3) `flushHeystack()` (and the drain handed to `waitUntil`) no longer rejects when an export fails, so a down ingest or a rejected key never surfaces as an error in your Worker. No API changes; upgrade and redeploy.
|
|
736
|
+
- **`0.19.0`** — **Cloudflare native spans, coverage gate and direct-mode parity, plus privacy defaults.** **Privacy defaults:** (1) Recorded URLs are redacted on every entry: userinfo stripped and sensitive query/fragment values (`key`, `apikey`, `token`, `code`, `access_token`, `X-Amz-Signature`, …) replaced with `REDACTED`; extend with `redactQueryParams: [...]` or opt out with `false`. (2) `client.address` is **no longer recorded by default** on `/workers` and is stripped from `/node` spans (with `http.client_ip` and SERVER-span peer IPs) — set `captureClientIp: true` to keep it. (3) `/workers` geo defaults to country only — `captureGeo: "full"` restores `geo.region`/`geo.city`/`geo.asn`. (4) `/web` strict replay masking now masks **all** page text (previously only inputs); use `data-hs-unmask` to reveal trusted elements, and a missing masking mode is treated as strict. (5) New `/workers` `trustInboundSampling` (default `true`). See [Privacy](#privacy-url-redaction--client-ip). **Cloudflare:** (1) **Binding attributes.** Every direct-mode binding span carries `cloudflare.binding.type` (`d1`, `kv`, `r2`, `durable_object`, `queue`, `ai`, `vectorize`, `workflow`, `ratelimit`, `analytics_engine`) and `cloudflare.binding.name` (the env key). **Rename:** the rate-limit type value `rate_limit` is now `ratelimit` (ingest accepts both). Queue spans add `cloudflare.queue.name`. (2) **D1** spans add `db.system.name: "cloudflare-d1"` and `db.query.text`; the older `db.system` / `db.statement` stay for one more minor version and will then be removed. (3) **Hashed keys (behaviour change).** KV keys and R2 object keys are no longer recorded raw: `kv.key` / `r2.key` / `cloudflare.do.storage.key` are replaced by `cloudflare.kv.query.keys`, `cloudflare.r2.request.key` and a hashed `cloudflare.do.storage.key` (the prefix through the first `:` or `/` is kept, the rest becomes `h:<16 hex>`; multi-key calls are comma-joined). If Web Crypto is unavailable the attribute is omitted, never the raw key. Hashing runs concurrently and never delays or changes the call; multi-key calls hash at most the first 10 keys and report the full count in `cloudflare.kv.query.key_count` / `cloudflare.r2.request.key_count`. **PII note:** the key prefix through the first `:` or `/` is kept verbatim (e.g. `avatars/h:…`), so never put personal data in key prefixes. Service-binding spans also carry `cloudflare.binding.type: "service"`. `wrapWorkerEntrypointClass` also traces the entrypoint's own `this.env` bindings when `instrumentBindings` is set. (4) **Durable Objects.** `ctx.storage.sql.exec()` emits `durable_object_storage_exec` (`db.query.text`, `cloudflare.durable_object.response.rows_read|rows_written` once the app finishes the cursor, `cloudflare.durable_object.id`); the cursor is never iterated by the SDK. `getAlarm` / `setAlarm` / `deleteAlarm` emit `durable_object_alarms_*`. WebSocket hibernation handlers are now named `DO <Class> webSocketMessage|webSocketClose|webSocketError` (previously `DO rpc …`) with `cloudflare.handler_type`. (5) **New `wrapWorkerEntrypointClass(cls, config)`** for `WorkerEntrypoint` classes: traces `fetch` like `instrument()` and each public RPC method as a SERVER span `rpc <method>` (`jsrpc.method`, `rpc.method`). RPC spans are direct-mode only; under native tracing Cloudflare already emits `jsrpc` spans, so RPC passes straight through. (6) The direct-mode resource now carries `cloud.provider: "cloudflare"` and `cloud.platform: "cloudflare.workers"`. **Migration notes (0.19):** (a) In native mode `http.route` is no longer the raw path: pass `routes` to get templated routes. (b) `kv.key` / `r2.key` are replaced by the hashed native attribute names above. (c) `db.system` / `db.statement` are deprecated in favour of `db.system.name` / `db.query.text`. (d) Binding type `rate_limit` is now `ratelimit`. (e) `CloudflareNativeTracing` / `CloudflareNativeSpan` remain exported as deprecated aliases of `CloudflareTracing` / `CloudflareSpan`. (f) Native enrichment is on for the kinds Cloudflare does not span itself (Workers AI, Vectorize, Workflows, Analytics Engine; Hyperdrive via `wrapSqlClient`); disable it per kind with `nativeEnrichment`, and an explicit `instrumentBindings` overrides the gate. Apps that already wrap AI calls manually may see a duplicate span until they opt out. (g) New `ai.gatewayExport`. (h) New `/workers` exports: `wrapWorkerEntrypointClass`, `wrapSqlClient`, `NATIVELY_COVERED`, `matchRoute`, and the types `CloudflareTracing`, `CloudflareSpan`, `BindingKind`.
|
|
618
737
|
- **`0.16.0`** — **complete direct Workers lifecycle and binding coverage.** Durable Object namespace stubs now trace `fetch` and RPC calls with W3C trace propagation; Workflow bindings trace creation, batch creation, lookup, instance lifecycle methods, and deletion operations. Direct instrumentation also covers scheduled, queue, email, Tail Worker, and related Worker entry points, while preserving synchronous values, native promises, custom thenables, upgrade responses, host-object identity, and callback-scoped native span ownership. Service-binding propagation now respects caller-supplied request headers. No breaking changes.
|
|
619
738
|
- **`0.15.0`** — **direct Workers D1 session and batch coverage.** With `nativeTracing: "direct"` and `instrumentBindings` enabled, prepared D1 statements, database/session `batch`, database `exec`, and statements created through `withSession` now receive child spans. Native Cloudflare OTel remains the recommended Workers path because it preserves Cloudflare's platform trace/log tree; use direct export only where `ctx.tracing` is unavailable. No breaking changes.
|
|
620
739
|
- **`0.14.0`** — **gen_ai enrichment under Cloudflare native tracing, Mistral + AI-gateway detection, streaming usage; OpenTelemetry JS SDK 2.x.** (1) `/workers`: under `nativeTracing: "auto" | "native"` `instrument()` never installed the fetch wrapper, so native-mode Workers emitted **no** `gen_ai.*` telemetry. It now installs a lean LLM-only wrapper that opens a native `<operation> <provider>` span (e.g. `chat mistral`) with request/response `gen_ai.*` for detected providers and passes every other subrequest through untouched. (2) New provider **Mistral** (`api.mistral.ai`) and a new `ai.gateways` option (`[{ hostSuffix, pathPattern }]`, e.g. `{ hostSuffix: "wafersecurity.ai", pathPattern: "/p/:tenant/:provider" }`) that resolves the provider from an AI-gateway URL and stamps `heystack.ai.gateway`; `gemini` → `google`, `workers-ai` → `cloudflare`. New `gen_ai.operation.name`. (3) **Streaming (`text/event-stream`) responses now yield usage**: the body is `tee()`d and scanned line-by-line for `usage`/`usageMetadata`, model, id and finish reason only (never content); the span stays open until the stream closes. Google (`usageMetadata`) and Mistral response shapes added to the JSON path too. (4) New `/workers` exports `genAiRequestAttrs` / `genAiResponseAttrs` for manual `withSpan` spans around `env.AI.run` or any non-`fetch` LLM call. Native `error.type` now keeps the exception class instead of being overwritten with `error`. (5) All `@opentelemetry/*` dependencies move to the 2.x SDK line (`core`/`sdk-trace-base`/`resources` ^2.11, `sdk-node`/`exporter-trace-otlp-http` ^0.222, `auto-instrumentations-node` ^0.80, `semantic-conventions` ^1.43), clearing the downstream Dependabot alerts the 1.x line surfaced through this package (GHSA-8988-4f7v-96qf in `@opentelemetry/core`, GHSA-q7rr-3cgh-j5r3 in `sdk-node` / `auto-instrumentations-node` / `exporter-prometheus`, GHSA-45rx-2jwx-cxfr in `propagator-jaeger`). **Node ≥ 18.19 (or ≥ 20.6) is now required on `/node` and `/next`** — the 2.x SDK dropped Node 14/16. If you pass your own `instrumentations` array to `initHeystack`, upgrade those `@opentelemetry/instrumentation-*` packages to a release that peers on `@opentelemetry/core@^2` (the auto-instrumentations 0.80 set already does). `/workers` is still WinterCG-safe (no `node:*` imports). No other API changes; all new options are optional.
|
|
621
|
-
- **`0.13.0`** — **Cloudflare-native tracing and
|
|
740
|
+
- **`0.13.0`** — **Cloudflare-native tracing and broad Workers coverage (see the [coverage table](#cloudflare-coverage)).** On runtimes exposing `ctx.tracing`, `/workers` now joins Cloudflare's authoritative invocation trace instead of creating and exporting a duplicate tree. `nativeTracing: "auto"` is the default; `withCloudflareSpan(ctx, ...)` adds async-safe business spans, while fetch, queue, and scheduled handlers are enriched in the native tree. The new `/workers/agents` entry maps Cloudflare Agents observability callbacks and Tail Worker diagnostics to structured, redacted telemetry. Direct OTLP remains available with `nativeTracing: "direct"` for runtimes without the native API. Heystack ingest now preserves full OTLP trace/log fidelity, accepts OTLP metrics, and can import Workers Analytics metrics through the app-scoped Cloudflare connector. Native mode needs no Worker API-key secret and must not be combined with direct export.
|
|
622
741
|
- **`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.
|
|
623
742
|
- **`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).
|
|
624
743
|
- **`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.
|
|
@@ -713,3 +832,8 @@ Emit `screen.viewed` for actual navigation, with stable `surface` and `sessionId
|
|
|
713
832
|
Emit feature attempts with `feature`, `attemptId` and `outcome`. Keep the same
|
|
714
833
|
user/session/attempt context across backend calls. See the [exact telemetry
|
|
715
834
|
contract and verification checklist](https://heystack.dev/docs/product-activity).
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
### Native-mode trace links
|
|
838
|
+
|
|
839
|
+
Cloudflare's native spans do not expose trace or span ids, and Cloudflare does not propagate W3C context to external services. So in native mode the SDK mints one synthetic trace/span pair per invocation (these are SDK ids, not Cloudflare's native ids), sends it as `traceparent` on outbound fetches (subject to `propagateTargets`; never to the Heystack ingest host), and records it on the root span as `heystack.propagation.trace_id` / `heystack.propagation.span_id`. A valid inbound `traceparent` is recorded as `heystack.parent.trace_id` / `heystack.parent.span_id`. Heystack turns these attributes into span links, so related invocations are connected without claiming a shared native trace. The SDK adds no `fetch` span of its own for ordinary subrequests (Cloudflare records those). Same-origin browser calls are already propagated by the browser SDK.
|
package/dist/core.d.ts
CHANGED
|
@@ -49,3 +49,4 @@ export interface ExporterConfig {
|
|
|
49
49
|
}
|
|
50
50
|
/** Pure: derive the OTLP/HTTP traces URL + auth header. Works in any runtime. */
|
|
51
51
|
export declare function buildExporterConfig(o: HeystackOptions): ExporterConfig;
|
|
52
|
+
export { redactUrl, makeUrlRedactor, REDACTED, DEFAULT_REDACTED_QUERY_PARAMS, type UrlRedactionOptions, type SpanPrivacyOptions, } from "./redact.js";
|
package/dist/core.js
CHANGED
|
@@ -30,3 +30,7 @@ export function buildExporterConfig(o) {
|
|
|
30
30
|
const base = (o.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, "");
|
|
31
31
|
return { url: `${base}/v1/traces`, headers: { Authorization: `Bearer ${o.apiKey}` } };
|
|
32
32
|
}
|
|
33
|
+
// Privacy helpers — pure and runtime-neutral, so they're safe from the core entry
|
|
34
|
+
// in any runtime (Node, Workers, browser). Use `redactUrl` to scrub URLs you log
|
|
35
|
+
// or attach to spans yourself, with the same rules the SDK applies.
|
|
36
|
+
export { redactUrl, makeUrlRedactor, REDACTED, DEFAULT_REDACTED_QUERY_PARAMS, } from "./redact.js";
|
package/dist/hash.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `"h:" + first 16 hex chars of sha256(value)` via Web Crypto (WinterCG-safe).
|
|
3
|
+
* Byte-identical to `hashToken` in `packages/schema/src/cf-normalize.ts`; kept
|
|
4
|
+
* as a separate implementation so the SDK never depends on the schema package.
|
|
5
|
+
*/
|
|
6
|
+
export declare function hashToken(value: string): Promise<string>;
|
|
7
|
+
/**
|
|
8
|
+
* Key scrub mirroring `scrubKey` in `packages/schema/src/cf-normalize.ts`: keep
|
|
9
|
+
* the prefix through the first `:` or `/`, hash the rest. Idempotent on the
|
|
10
|
+
* `h:` marker. Rejects if Web Crypto is unavailable (callers omit the attribute;
|
|
11
|
+
* they must never fall back to the raw key).
|
|
12
|
+
*/
|
|
13
|
+
export declare function scrubKeyAsync(key: string): Promise<string>;
|
|
14
|
+
/**
|
|
15
|
+
* Hash one or several keys into a comma-joined attribute value. Resolves
|
|
16
|
+
* `undefined` (never rejects, never returns raw text) when there is nothing to
|
|
17
|
+
* hash or any element cannot be hashed.
|
|
18
|
+
*/
|
|
19
|
+
export declare function scrubKeysAsync(keys: readonly string[]): Promise<string | undefined>;
|
package/dist/hash.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `"h:" + first 16 hex chars of sha256(value)` via Web Crypto (WinterCG-safe).
|
|
3
|
+
* Byte-identical to `hashToken` in `packages/schema/src/cf-normalize.ts`; kept
|
|
4
|
+
* as a separate implementation so the SDK never depends on the schema package.
|
|
5
|
+
*/
|
|
6
|
+
export async function hashToken(value) {
|
|
7
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
8
|
+
let hex = "";
|
|
9
|
+
for (const b of new Uint8Array(digest).slice(0, 8))
|
|
10
|
+
hex += b.toString(16).padStart(2, "0");
|
|
11
|
+
return "h:" + hex;
|
|
12
|
+
}
|
|
13
|
+
const HASHED_WHOLE = /^h:[0-9a-f]{16}$/;
|
|
14
|
+
const HASHED_TAIL = /^[^]*[:/]h:[0-9a-f]{16}$/;
|
|
15
|
+
/**
|
|
16
|
+
* Key scrub mirroring `scrubKey` in `packages/schema/src/cf-normalize.ts`: keep
|
|
17
|
+
* the prefix through the first `:` or `/`, hash the rest. Idempotent on the
|
|
18
|
+
* `h:` marker. Rejects if Web Crypto is unavailable (callers omit the attribute;
|
|
19
|
+
* they must never fall back to the raw key).
|
|
20
|
+
*/
|
|
21
|
+
export async function scrubKeyAsync(key) {
|
|
22
|
+
if (HASHED_WHOLE.test(key) || HASHED_TAIL.test(key))
|
|
23
|
+
return key;
|
|
24
|
+
const m = /[:/]/.exec(key);
|
|
25
|
+
if (!m)
|
|
26
|
+
return hashToken(key);
|
|
27
|
+
const cut = m.index + 1;
|
|
28
|
+
const tail = key.slice(cut);
|
|
29
|
+
if (HASHED_WHOLE.test(tail))
|
|
30
|
+
return key;
|
|
31
|
+
return key.slice(0, cut) + (await hashToken(tail));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Hash one or several keys into a comma-joined attribute value. Resolves
|
|
35
|
+
* `undefined` (never rejects, never returns raw text) when there is nothing to
|
|
36
|
+
* hash or any element cannot be hashed.
|
|
37
|
+
*/
|
|
38
|
+
export async function scrubKeysAsync(keys) {
|
|
39
|
+
if (keys.length === 0)
|
|
40
|
+
return undefined;
|
|
41
|
+
try {
|
|
42
|
+
return (await Promise.all(keys.map(scrubKeyAsync))).join(",");
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
package/dist/next.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { HeystackOptions } from "./core.js";
|
|
2
|
+
import type { SpanPrivacyOptions } from "./redact.js";
|
|
2
3
|
/**
|
|
3
4
|
* Call from Next.js instrumentation.ts:
|
|
4
5
|
* export function register() { registerHeystack({ service: "my-app" }); }
|
|
@@ -19,6 +20,6 @@ import type { HeystackOptions } from "./core.js";
|
|
|
19
20
|
* `flushHeystack()` from `@heystack/otel/workers` in a response hook (or via
|
|
20
21
|
* `ctx.waitUntil(flushHeystack())` if you have a ctx) — it awaits the export.
|
|
21
22
|
*/
|
|
22
|
-
export declare function registerHeystack(o: Partial<HeystackOptions> & {
|
|
23
|
+
export declare function registerHeystack(o: Partial<HeystackOptions> & SpanPrivacyOptions & {
|
|
23
24
|
service: string;
|
|
24
25
|
}): Promise<void>;
|
package/dist/next.js
CHANGED
|
@@ -63,6 +63,9 @@ export async function registerHeystack(o) {
|
|
|
63
63
|
// Release/commit attribution — carried into the workerd resource.
|
|
64
64
|
version: o.version,
|
|
65
65
|
build: o.build,
|
|
66
|
+
// Privacy: URL redaction + client-IP capture (both runtime paths).
|
|
67
|
+
redactQueryParams: o.redactQueryParams,
|
|
68
|
+
captureClientIp: o.captureClientIp,
|
|
66
69
|
});
|
|
67
70
|
}
|
|
68
71
|
else {
|
|
@@ -75,6 +78,8 @@ export async function registerHeystack(o) {
|
|
|
75
78
|
// Release/commit attribution — carried into the NodeSDK resource.
|
|
76
79
|
version: o.version,
|
|
77
80
|
build: o.build,
|
|
81
|
+
redactQueryParams: o.redactQueryParams,
|
|
82
|
+
captureClientIp: o.captureClientIp,
|
|
78
83
|
debug: o.debug,
|
|
79
84
|
});
|
|
80
85
|
}
|
package/dist/node.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentation
|
|
|
3
3
|
import { type ExportResult } from "@opentelemetry/core";
|
|
4
4
|
import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base";
|
|
5
5
|
import { type HeystackOptions } from "./core.js";
|
|
6
|
+
import { type SpanPrivacyOptions } from "./redact.js";
|
|
6
7
|
/**
|
|
7
8
|
* The element type the NodeSDK `instrumentations` config accepts (an OTel
|
|
8
9
|
* `Instrumentation` or array thereof). Derived from `getNodeAutoInstrumentations`
|
|
@@ -10,7 +11,7 @@ import { type HeystackOptions } from "./core.js";
|
|
|
10
11
|
* for a type.
|
|
11
12
|
*/
|
|
12
13
|
type InstrumentationConfigItem = ReturnType<typeof getNodeAutoInstrumentations>[number];
|
|
13
|
-
export interface NodeOptions extends HeystackOptions {
|
|
14
|
+
export interface NodeOptions extends HeystackOptions, SpanPrivacyOptions {
|
|
14
15
|
/** Enable OTel diagnostic logging to console to confirm export. */
|
|
15
16
|
debug?: boolean;
|
|
16
17
|
/** Set false to skip auto-instrumentations (you'll then only get framework/manual spans). Default true. */
|
|
@@ -45,9 +46,20 @@ export declare class SelfSpanFilteringExporter implements SpanExporter {
|
|
|
45
46
|
private readonly inner;
|
|
46
47
|
/** Bare ingest hostname (lower-case, no port), e.g. `ingest.heystack.dev`. */
|
|
47
48
|
private readonly ingestHost;
|
|
49
|
+
private readonly redact;
|
|
50
|
+
private readonly captureClientIp;
|
|
48
51
|
constructor(inner: SpanExporter,
|
|
49
52
|
/** Bare ingest hostname (lower-case, no port), e.g. `ingest.heystack.dev`. */
|
|
50
|
-
ingestHost: string
|
|
53
|
+
ingestHost: string,
|
|
54
|
+
/**
|
|
55
|
+
* Privacy pass applied to every exported span: URL attributes (`url.full`,
|
|
56
|
+
* `http.url`, `http.target`, `url.query`, …) are redacted and end-user IP
|
|
57
|
+
* attributes are dropped unless `captureClientIp`. Applied at the exporter
|
|
58
|
+
* boundary so it covers every instrumentation (http, undici, Next.js, …).
|
|
59
|
+
*/
|
|
60
|
+
privacy?: SpanPrivacyOptions);
|
|
61
|
+
/** Return `span` with privacy-filtered attributes (same object if unchanged). */
|
|
62
|
+
private scrub;
|
|
51
63
|
export(spans: ReadableSpan[], cb: (result: ExportResult) => void): void;
|
|
52
64
|
shutdown(): Promise<void>;
|
|
53
65
|
forceFlush(): Promise<void>;
|
package/dist/node.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { NodeSDK } from "@opentelemetry/sdk-node";
|
|
2
2
|
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
3
3
|
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
|
|
4
|
-
import { diag, DiagConsoleLogger, DiagLogLevel } from "@opentelemetry/api";
|
|
4
|
+
import { diag, DiagConsoleLogger, DiagLogLevel, SpanKind } from "@opentelemetry/api";
|
|
5
5
|
import { ExportResultCode } from "@opentelemetry/core";
|
|
6
6
|
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
7
7
|
import { buildExporterConfig, releaseResourceAttributes, } from "./core.js";
|
|
8
8
|
import { isSelfSpanAttrs, safeHostname } from "./self-span.js";
|
|
9
|
+
import { applySpanPrivacy, makeUrlRedactor, } from "./redact.js";
|
|
9
10
|
/**
|
|
10
11
|
* Wraps a span exporter and drops any span that targets the Heystack ingest
|
|
11
12
|
* origin before it is exported. On Node/Next the runtime auto-instruments
|
|
@@ -24,11 +25,55 @@ import { isSelfSpanAttrs, safeHostname } from "./self-span.js";
|
|
|
24
25
|
export class SelfSpanFilteringExporter {
|
|
25
26
|
inner;
|
|
26
27
|
ingestHost;
|
|
28
|
+
redact;
|
|
29
|
+
captureClientIp;
|
|
27
30
|
constructor(inner,
|
|
28
31
|
/** Bare ingest hostname (lower-case, no port), e.g. `ingest.heystack.dev`. */
|
|
29
|
-
ingestHost
|
|
32
|
+
ingestHost,
|
|
33
|
+
/**
|
|
34
|
+
* Privacy pass applied to every exported span: URL attributes (`url.full`,
|
|
35
|
+
* `http.url`, `http.target`, `url.query`, …) are redacted and end-user IP
|
|
36
|
+
* attributes are dropped unless `captureClientIp`. Applied at the exporter
|
|
37
|
+
* boundary so it covers every instrumentation (http, undici, Next.js, …).
|
|
38
|
+
*/
|
|
39
|
+
privacy = {}) {
|
|
30
40
|
this.inner = inner;
|
|
31
41
|
this.ingestHost = ingestHost;
|
|
42
|
+
this.redact = makeUrlRedactor(privacy);
|
|
43
|
+
this.captureClientIp = privacy.captureClientIp === true;
|
|
44
|
+
}
|
|
45
|
+
/** Return `span` with privacy-filtered attributes (same object if unchanged). */
|
|
46
|
+
scrub(span) {
|
|
47
|
+
const attrs = span.attributes;
|
|
48
|
+
const next = applySpanPrivacy(attrs, {
|
|
49
|
+
redact: this.redact,
|
|
50
|
+
captureClientIp: this.captureClientIp,
|
|
51
|
+
isServer: span.kind === SpanKind.SERVER,
|
|
52
|
+
});
|
|
53
|
+
if (next === attrs)
|
|
54
|
+
return span;
|
|
55
|
+
// Explicit copy (rather than mutating the ended span or prototype tricks) so
|
|
56
|
+
// the OTLP serializer sees a plain ReadableSpan with the scrubbed attributes.
|
|
57
|
+
const sc = span.spanContext();
|
|
58
|
+
return {
|
|
59
|
+
name: span.name,
|
|
60
|
+
kind: span.kind,
|
|
61
|
+
spanContext: () => sc,
|
|
62
|
+
parentSpanContext: span.parentSpanContext,
|
|
63
|
+
startTime: span.startTime,
|
|
64
|
+
endTime: span.endTime,
|
|
65
|
+
status: span.status,
|
|
66
|
+
attributes: next,
|
|
67
|
+
links: span.links,
|
|
68
|
+
events: span.events,
|
|
69
|
+
duration: span.duration,
|
|
70
|
+
ended: span.ended,
|
|
71
|
+
resource: span.resource,
|
|
72
|
+
instrumentationScope: span.instrumentationScope,
|
|
73
|
+
droppedAttributesCount: span.droppedAttributesCount,
|
|
74
|
+
droppedEventsCount: span.droppedEventsCount,
|
|
75
|
+
droppedLinksCount: span.droppedLinksCount,
|
|
76
|
+
};
|
|
32
77
|
}
|
|
33
78
|
export(spans, cb) {
|
|
34
79
|
const kept = this.ingestHost
|
|
@@ -40,7 +85,7 @@ export class SelfSpanFilteringExporter {
|
|
|
40
85
|
cb({ code: ExportResultCode.SUCCESS });
|
|
41
86
|
return;
|
|
42
87
|
}
|
|
43
|
-
this.inner.export(kept, cb);
|
|
88
|
+
this.inner.export(kept.map((s) => this.scrub(s)), cb);
|
|
44
89
|
}
|
|
45
90
|
shutdown() {
|
|
46
91
|
return this.inner.shutdown();
|
|
@@ -126,7 +171,7 @@ export function initHeystack(o) {
|
|
|
126
171
|
[getNodeAutoInstrumentations(ingestIgnoreConfig(ingestHost))]);
|
|
127
172
|
// Universal defence: filter any self-span at the exporter boundary, so the
|
|
128
173
|
// loop is killed regardless of which instrumentations are active.
|
|
129
|
-
const traceExporter = new SelfSpanFilteringExporter(new OTLPTraceExporter({ url: cfg.url, headers: cfg.headers }), ingestHost);
|
|
174
|
+
const traceExporter = new SelfSpanFilteringExporter(new OTLPTraceExporter({ url: cfg.url, headers: cfg.headers }), ingestHost, { redactQueryParams: o.redactQueryParams, captureClientIp: o.captureClientIp });
|
|
130
175
|
const sdk = new NodeSDK({
|
|
131
176
|
serviceName: o.service,
|
|
132
177
|
// Release/commit attribution. NodeSDK merges `serviceName` on top of this
|