@heystack/otel 0.3.4 → 0.4.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 +46 -0
- package/dist/web.d.ts +84 -0
- package/dist/web.js +211 -0
- package/dist/workers.d.ts +3 -3
- package/package.json +33 -10
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ 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` on outgoing fetch. No-op on the server (SSR-safe). |
|
|
25
26
|
| Anywhere (pure helpers) | `@heystack/otel` | `buildExporterConfig`, types. No Node SDK loaded. |
|
|
26
27
|
|
|
27
28
|
## Node / Express / etc.
|
|
@@ -141,6 +142,50 @@ export class Counter {
|
|
|
141
142
|
|
|
142
143
|
The default export still needs to be wrapped with `instrument()` (or `initHeystackWorkers()` called) so the global provider + ContextManager are registered before the DO runs.
|
|
143
144
|
|
|
145
|
+
## `@heystack/otel/web` (browser / session replay)
|
|
146
|
+
|
|
147
|
+
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.
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { instrumentWeb } from "@heystack/otel/web";
|
|
151
|
+
|
|
152
|
+
const stop = await instrumentWeb({
|
|
153
|
+
apiKey: process.env.HEYSTACK_API_KEY, // same ingest key as the rest of the SDK
|
|
154
|
+
service: "my-web-app",
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// later, e.g. on unmount / sign-out:
|
|
158
|
+
stop();
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`instrumentWeb` returns a `stop()` function that ends the recording session.
|
|
162
|
+
|
|
163
|
+
### Options
|
|
164
|
+
|
|
165
|
+
| Option | Type | Notes |
|
|
166
|
+
| --- | --- | --- |
|
|
167
|
+
| `apiKey` | `string` | **Required.** The same Heystack ingest key used by the rest of the SDK. |
|
|
168
|
+
| `service` | `string` | **Required.** The OTel service name (matches the app's service in the console). |
|
|
169
|
+
| `userId` | `string?` | Optional app-supplied identifier stamped on the session. |
|
|
170
|
+
| `endpoint` | `string?` | Optional ingest endpoint override (defaults to the Heystack ingest endpoint). |
|
|
171
|
+
| `sampleRate` | `number?` | Optional **local** override for the recording sample rate (0–1). By default sampling is controlled from the console. |
|
|
172
|
+
| `flushIntervalMs` | `number?` | How often buffered events are flushed (default 5000ms). |
|
|
173
|
+
| `flushEveryEvents` | `number?` | Max buffered events before an early flush (default 200). |
|
|
174
|
+
|
|
175
|
+
### Sampling & masking come from the console
|
|
176
|
+
|
|
177
|
+
By default **sampling and masking are controlled from the Heystack console** — enable replay and tune the sample rate / masking mode under **Settings → Session replay** for the app. `sampleRate` is only an optional local override; you don't need to pass it.
|
|
178
|
+
|
|
179
|
+
### Privacy / masking (rrweb)
|
|
180
|
+
|
|
181
|
+
Masking is **strict by default**: all text inputs and passwords are masked before anything leaves the browser. For finer control, annotate your DOM:
|
|
182
|
+
|
|
183
|
+
| Attribute | Effect |
|
|
184
|
+
| --- | --- |
|
|
185
|
+
| `data-hs-mask` | Masks an element's text. |
|
|
186
|
+
| `data-hs-block` | Blocks a region entirely (it's not recorded). |
|
|
187
|
+
| `data-hs-unmask` | Reveals an element you explicitly trust (un-masks it). |
|
|
188
|
+
|
|
144
189
|
## Flushing
|
|
145
190
|
|
|
146
191
|
On Workers/edge the export is a `fetch()` POST, and the isolate can be torn down the instant your handler returns. **You must let that POST complete or the trace is silently dropped** — this is the #1 cause of flaky Workers tracing. `flushHeystack()` and `instrument()`'s built-in flush both await the in-flight fetch (not just the OTel span processor, which does *not* wait for it).
|
|
@@ -157,6 +202,7 @@ As belt-and-suspenders the exporter also drops any span whose HTTP target points
|
|
|
157
202
|
|
|
158
203
|
## Migration / versioning
|
|
159
204
|
|
|
205
|
+
- **`0.3.5`** — **type-constraint fix (Workers).** A Worker whose `queue` consumer is typed with a concrete message body — `queue(batch: MessageBatch<MyJob>, …)`, the normal case — failed to compile against `instrument()` in 0.3.4 (`TS2345`: `MessageBatch<unknown>` not assignable to `MessageBatch<MyJob>`). The `WorkerHandler` constraint declared its entrypoints as arrow properties, whose parameters are checked **contravariantly** under `strictFunctionTypes`, so a narrowed handler wasn't assignable. 0.3.5 declares them with **method syntax** (bivariant parameters) and widens the batch to `MessageBatch<any>`, mirroring Cloudflare's own `ExportedHandler` — a typed-queue Worker now type-checks with a bare `instrument(handler, cfg)`. Runtime behaviour unchanged. Also adds a **consumer type-check gate** (`pnpm consumer-typecheck`, run in `check` and `prepublishOnly`) that compiles a fully-typed Worker against the built `dist` through the public `exports` map and asserts `satisfies ExportedHandler<Env>` — the regression that escaped in 0.3.3/0.3.4 now fails the build before publish.
|
|
160
206
|
- **`0.3.4`** — **type-inference fix (Workers).** Restores `instrument()`'s ability to infer the handler's concrete `Env` type. In 0.3.3 the signature was `instrument<E = unknown, H extends WorkerHandler<E>>`, so `E` defaulted to `unknown` and was never recovered from the handler — under `strictFunctionTypes` a Worker typed `fetch(req, env: Env, ctx)` then failed to compile (`TS2345: 'Env' is not assignable to 'unknown'`) unless the caller passed `instrument<Env>(...)` explicitly. 0.3.4 infers `E` from the handler argument (`instrument<H extends WorkerHandler<any>>(...): Instrumented<EnvOf<H>, H>`), so a bare `instrument(handler, cfg)` type-checks again. Runtime behaviour is unchanged; no `0.3.3` consumer needs the explicit type arg after upgrading.
|
|
161
207
|
- **`0.3.3`** — `/next` uses bare, exports-mapped dynamic imports so the OpenNext (Cloudflare Pages) build resolves the workers entry correctly (fixes a build break). Workers/Node paths unchanged.
|
|
162
208
|
- **`0.3.2`** — runtime-correctness fixes:
|
package/dist/web.d.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** The replay chunk metadata sent alongside the events array. Defined locally so
|
|
2
|
+
* the published SDK does not depend on the private @heystack/schema package. */
|
|
3
|
+
export interface ReplayChunkMeta {
|
|
4
|
+
start_url: string;
|
|
5
|
+
event_count: number;
|
|
6
|
+
click_count: number;
|
|
7
|
+
error_count: number;
|
|
8
|
+
has_snapshot: boolean;
|
|
9
|
+
user_id?: string;
|
|
10
|
+
browser?: string;
|
|
11
|
+
device?: string;
|
|
12
|
+
trace_ids?: string[];
|
|
13
|
+
/** Client epoch millis for this chunk's first event; falls back to ingest time. */
|
|
14
|
+
ts?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface RecorderConfig {
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
sample_rate: number;
|
|
19
|
+
masking_mode: "strict" | "relaxed";
|
|
20
|
+
}
|
|
21
|
+
/** Pure: decide once per session whether to record. rng defaults to Math.random. */
|
|
22
|
+
export declare function shouldRecord(cfg: RecorderConfig, rng?: () => number): boolean;
|
|
23
|
+
export interface ChunkCtx {
|
|
24
|
+
url: string;
|
|
25
|
+
errorCount: number;
|
|
26
|
+
userId?: string;
|
|
27
|
+
browser?: string;
|
|
28
|
+
device?: string;
|
|
29
|
+
traceIds?: string[];
|
|
30
|
+
}
|
|
31
|
+
interface RRWebEventish {
|
|
32
|
+
type?: number;
|
|
33
|
+
timestamp?: number;
|
|
34
|
+
data?: {
|
|
35
|
+
source?: number;
|
|
36
|
+
type?: number;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Pure: derive the per-chunk metadata sent alongside the events array. */
|
|
40
|
+
export declare function computeChunkMeta(events: RRWebEventish[], ctx: ChunkCtx): ReplayChunkMeta;
|
|
41
|
+
/** Browser gzip via the platform CompressionStream. */
|
|
42
|
+
export declare function gzipString(text: string): Promise<Uint8Array>;
|
|
43
|
+
export interface TransportOpts {
|
|
44
|
+
endpoint: string;
|
|
45
|
+
apiKey: string;
|
|
46
|
+
sessionId: string;
|
|
47
|
+
fetchImpl?: typeof fetch;
|
|
48
|
+
gzipImpl?: (s: string) => Promise<Uint8Array>;
|
|
49
|
+
}
|
|
50
|
+
/** Batches -> gzips -> POSTs replay chunks, tracking the monotonic chunk_seq. */
|
|
51
|
+
export declare class ReplayTransport {
|
|
52
|
+
private seq;
|
|
53
|
+
private readonly o;
|
|
54
|
+
constructor(opts: TransportOpts);
|
|
55
|
+
send(events: unknown[], ctx: ChunkCtx, keepalive?: boolean): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
export interface InstrumentWebOptions {
|
|
58
|
+
apiKey: string;
|
|
59
|
+
service: string;
|
|
60
|
+
endpoint?: string;
|
|
61
|
+
/** Optional app-supplied user identifier stamped on the session. */
|
|
62
|
+
userId?: string;
|
|
63
|
+
/** Local overrides; by default sampling + masking come from server config. */
|
|
64
|
+
sampleRate?: number;
|
|
65
|
+
flushIntervalMs?: number;
|
|
66
|
+
/** Max buffered events before an early flush. */
|
|
67
|
+
flushEveryEvents?: number;
|
|
68
|
+
}
|
|
69
|
+
/** Entry point: fetch config, decide sampling, start rrweb, stream chunks.
|
|
70
|
+
* Returns a stop() function. Safe to call in any browser; no-ops on the server. */
|
|
71
|
+
export declare function instrumentWeb(opts: InstrumentWebOptions): Promise<() => void>;
|
|
72
|
+
export declare function makeTraceparent(traceId: string, spanId: string): string;
|
|
73
|
+
/** Collects trace ids observed during a session (deduped, capped). */
|
|
74
|
+
export declare class TraceIdCollector {
|
|
75
|
+
private readonly cap;
|
|
76
|
+
private readonly ids;
|
|
77
|
+
constructor(cap?: number);
|
|
78
|
+
add(id: string): void;
|
|
79
|
+
drain(): string[];
|
|
80
|
+
}
|
|
81
|
+
/** Patch window.fetch to inject traceparent on outgoing calls and record the
|
|
82
|
+
* trace id for correlation. Returns an unpatch function. */
|
|
83
|
+
export declare function patchFetchForCorrelation(collector: TraceIdCollector): () => void;
|
|
84
|
+
export {};
|
package/dist/web.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { DEFAULT_ENDPOINT } from "./core.js";
|
|
2
|
+
/** Pure: decide once per session whether to record. rng defaults to Math.random. */
|
|
3
|
+
export function shouldRecord(cfg, rng = Math.random) {
|
|
4
|
+
if (!cfg.enabled)
|
|
5
|
+
return false;
|
|
6
|
+
return rng() < cfg.sample_rate;
|
|
7
|
+
}
|
|
8
|
+
// rrweb event-shape constants (kept local so we don't import rrweb types into the pure layer).
|
|
9
|
+
const FULL_SNAPSHOT = 2;
|
|
10
|
+
const INCREMENTAL = 3;
|
|
11
|
+
const SOURCE_MOUSE_INTERACTION = 2;
|
|
12
|
+
const MOUSE_CLICK = 2;
|
|
13
|
+
/** Pure: derive the per-chunk metadata sent alongside the events array. */
|
|
14
|
+
export function computeChunkMeta(events, ctx) {
|
|
15
|
+
let clicks = 0;
|
|
16
|
+
let hasSnapshot = false;
|
|
17
|
+
let earliest = Number.POSITIVE_INFINITY;
|
|
18
|
+
for (const e of events) {
|
|
19
|
+
if (e.type === FULL_SNAPSHOT)
|
|
20
|
+
hasSnapshot = true;
|
|
21
|
+
if (e.type === INCREMENTAL && e.data?.source === SOURCE_MOUSE_INTERACTION && e.data?.type === MOUSE_CLICK) {
|
|
22
|
+
clicks++;
|
|
23
|
+
}
|
|
24
|
+
if (typeof e.timestamp === "number" && e.timestamp < earliest)
|
|
25
|
+
earliest = e.timestamp;
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
start_url: ctx.url,
|
|
29
|
+
event_count: events.length,
|
|
30
|
+
click_count: clicks,
|
|
31
|
+
error_count: ctx.errorCount,
|
|
32
|
+
has_snapshot: hasSnapshot,
|
|
33
|
+
user_id: ctx.userId,
|
|
34
|
+
browser: ctx.browser,
|
|
35
|
+
device: ctx.device,
|
|
36
|
+
trace_ids: ctx.traceIds ?? [],
|
|
37
|
+
ts: Number.isFinite(earliest) ? earliest : Date.now(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Browser gzip via the platform CompressionStream. */
|
|
41
|
+
export async function gzipString(text) {
|
|
42
|
+
const stream = new Response(text).body.pipeThrough(new CompressionStream("gzip"));
|
|
43
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
44
|
+
}
|
|
45
|
+
/** Batches -> gzips -> POSTs replay chunks, tracking the monotonic chunk_seq. */
|
|
46
|
+
export class ReplayTransport {
|
|
47
|
+
seq = 0;
|
|
48
|
+
o;
|
|
49
|
+
constructor(opts) {
|
|
50
|
+
this.o = {
|
|
51
|
+
fetchImpl: opts.fetchImpl ?? fetch.bind(globalThis),
|
|
52
|
+
gzipImpl: opts.gzipImpl ?? gzipString,
|
|
53
|
+
...opts,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async send(events, ctx, keepalive = false) {
|
|
57
|
+
if (events.length === 0)
|
|
58
|
+
return;
|
|
59
|
+
const meta = computeChunkMeta(events, ctx);
|
|
60
|
+
const body = JSON.stringify({ session_id: this.o.sessionId, chunk_seq: this.seq++, events, meta });
|
|
61
|
+
const bytes = await this.o.gzipImpl(body);
|
|
62
|
+
await this.o.fetchImpl(`${this.o.endpoint}/v1/replay`, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
keepalive,
|
|
65
|
+
headers: {
|
|
66
|
+
authorization: `Bearer ${this.o.apiKey}`,
|
|
67
|
+
"content-type": "application/json",
|
|
68
|
+
"content-encoding": "gzip",
|
|
69
|
+
},
|
|
70
|
+
// `Uint8Array` is a valid fetch body at runtime in every browser/edge
|
|
71
|
+
// runtime; the cast bridges the DOM vs @cloudflare/workers-types BodyInit
|
|
72
|
+
// typings (the latter is pulled in globally by workers.ts's triple-slash
|
|
73
|
+
// reference and narrows BodyInit to exclude Uint8Array).
|
|
74
|
+
body: bytes,
|
|
75
|
+
}).catch(() => { });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const DEFAULT_FLUSH_MS = 5000;
|
|
79
|
+
const DEFAULT_FLUSH_EVENTS = 200;
|
|
80
|
+
/** Entry point: fetch config, decide sampling, start rrweb, stream chunks.
|
|
81
|
+
* Returns a stop() function. Safe to call in any browser; no-ops on the server. */
|
|
82
|
+
export async function instrumentWeb(opts) {
|
|
83
|
+
if (typeof window === "undefined" || typeof document === "undefined")
|
|
84
|
+
return () => { };
|
|
85
|
+
const endpoint = (opts.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, "");
|
|
86
|
+
// 1. Fetch per-app config.
|
|
87
|
+
let cfg = { enabled: false, sample_rate: opts.sampleRate ?? 0, masking_mode: "strict" };
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(`${endpoint}/v1/replay/config`, { headers: { authorization: `Bearer ${opts.apiKey}` } });
|
|
90
|
+
if (res.ok)
|
|
91
|
+
cfg = await res.json();
|
|
92
|
+
}
|
|
93
|
+
catch { /* offline / blocked - fall back to disabled */ }
|
|
94
|
+
if (opts.sampleRate !== undefined)
|
|
95
|
+
cfg = { ...cfg, sample_rate: opts.sampleRate };
|
|
96
|
+
// 2. Session-level sampling decision.
|
|
97
|
+
if (!shouldRecord(cfg))
|
|
98
|
+
return () => { };
|
|
99
|
+
// 3. Start the recorder.
|
|
100
|
+
const { record } = await import("rrweb");
|
|
101
|
+
// An element marked `data-hs-unmask` (or any descendant of one) is recorded in
|
|
102
|
+
// cleartext; everything else is masked. rrweb 2.0.1's real opt-out hooks are
|
|
103
|
+
// `maskInputFn`/`maskTextFn` — there is no `unmaskTextSelector` option.
|
|
104
|
+
// Signatures (from rrweb-snapshot 2.0.1):
|
|
105
|
+
// MaskInputFn = (text: string, element: HTMLElement) => string
|
|
106
|
+
// MaskTextFn = (text: string, element: HTMLElement | null) => string
|
|
107
|
+
// `maskTextFn`'s element can be null, so the `?.closest?.` chain is guarded.
|
|
108
|
+
const reveal = (text, element) => !!element?.closest?.("[data-hs-unmask]");
|
|
109
|
+
const maskInputFn = (text, element) => reveal(text, element) ? text : "*".repeat(text.length);
|
|
110
|
+
const maskTextFn = (text, element) => reveal(text, element) ? text : "*".repeat(text.length);
|
|
111
|
+
const sessionId = crypto.randomUUID();
|
|
112
|
+
const transport = new ReplayTransport({ endpoint, apiKey: opts.apiKey, sessionId });
|
|
113
|
+
// Install the fetch patch AFTER the transport is constructed so the recorder's
|
|
114
|
+
// own upload POSTs (which captured the original fetch at construction time via
|
|
115
|
+
// fetch.bind(globalThis)) are not self-traced.
|
|
116
|
+
const traces = new TraceIdCollector();
|
|
117
|
+
const unpatch = patchFetchForCorrelation(traces);
|
|
118
|
+
let buffer = [];
|
|
119
|
+
let errorCount = 0;
|
|
120
|
+
const browser = navigator.userAgent;
|
|
121
|
+
const device = matchMediaDevice();
|
|
122
|
+
const onError = () => { errorCount++; };
|
|
123
|
+
window.addEventListener("error", onError);
|
|
124
|
+
window.addEventListener("unhandledrejection", onError);
|
|
125
|
+
const flush = (keepalive = false) => {
|
|
126
|
+
if (buffer.length === 0)
|
|
127
|
+
return;
|
|
128
|
+
const events = buffer;
|
|
129
|
+
buffer = [];
|
|
130
|
+
const errs = errorCount;
|
|
131
|
+
errorCount = 0;
|
|
132
|
+
void transport.send(events, {
|
|
133
|
+
url: location.href, errorCount: errs, userId: opts.userId, browser, device,
|
|
134
|
+
traceIds: traces.drain(),
|
|
135
|
+
}, keepalive);
|
|
136
|
+
};
|
|
137
|
+
const stopRecord = record({
|
|
138
|
+
emit(event) {
|
|
139
|
+
buffer.push(event);
|
|
140
|
+
if (buffer.length >= (opts.flushEveryEvents ?? DEFAULT_FLUSH_EVENTS))
|
|
141
|
+
flush();
|
|
142
|
+
},
|
|
143
|
+
maskAllInputs: cfg.masking_mode === "strict",
|
|
144
|
+
maskTextSelector: "[data-hs-mask]",
|
|
145
|
+
blockSelector: "[data-hs-block]",
|
|
146
|
+
maskInputFn,
|
|
147
|
+
maskTextFn,
|
|
148
|
+
});
|
|
149
|
+
const interval = setInterval(() => flush(), opts.flushIntervalMs ?? DEFAULT_FLUSH_MS);
|
|
150
|
+
const onHide = () => { if (document.visibilityState === "hidden")
|
|
151
|
+
flush(true); };
|
|
152
|
+
document.addEventListener("visibilitychange", onHide);
|
|
153
|
+
window.addEventListener("pagehide", () => flush(true));
|
|
154
|
+
return () => {
|
|
155
|
+
clearInterval(interval);
|
|
156
|
+
document.removeEventListener("visibilitychange", onHide);
|
|
157
|
+
window.removeEventListener("error", onError);
|
|
158
|
+
window.removeEventListener("unhandledrejection", onError);
|
|
159
|
+
stopRecord?.();
|
|
160
|
+
unpatch();
|
|
161
|
+
flush(true);
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function matchMediaDevice() {
|
|
165
|
+
if (typeof matchMedia === "undefined")
|
|
166
|
+
return "unknown";
|
|
167
|
+
return matchMedia("(max-width: 768px)").matches ? "mobile" : "desktop";
|
|
168
|
+
}
|
|
169
|
+
function randHex(bytes) {
|
|
170
|
+
const a = new Uint8Array(bytes);
|
|
171
|
+
crypto.getRandomValues(a);
|
|
172
|
+
return Array.from(a, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
173
|
+
}
|
|
174
|
+
export function makeTraceparent(traceId, spanId) {
|
|
175
|
+
return `00-${traceId}-${spanId}-01`;
|
|
176
|
+
}
|
|
177
|
+
/** Collects trace ids observed during a session (deduped, capped). */
|
|
178
|
+
export class TraceIdCollector {
|
|
179
|
+
cap;
|
|
180
|
+
ids = new Set();
|
|
181
|
+
constructor(cap = 50) {
|
|
182
|
+
this.cap = cap;
|
|
183
|
+
}
|
|
184
|
+
add(id) {
|
|
185
|
+
if (this.ids.size >= this.cap || this.ids.has(id))
|
|
186
|
+
return;
|
|
187
|
+
this.ids.add(id);
|
|
188
|
+
}
|
|
189
|
+
drain() {
|
|
190
|
+
const out = [...this.ids];
|
|
191
|
+
this.ids.clear();
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** Patch window.fetch to inject traceparent on outgoing calls and record the
|
|
196
|
+
* trace id for correlation. Returns an unpatch function. */
|
|
197
|
+
export function patchFetchForCorrelation(collector) {
|
|
198
|
+
if (typeof window === "undefined" || !window.fetch)
|
|
199
|
+
return () => { };
|
|
200
|
+
const orig = window.fetch.bind(window);
|
|
201
|
+
window.fetch = ((input, init) => {
|
|
202
|
+
const traceId = randHex(16);
|
|
203
|
+
const spanId = randHex(8);
|
|
204
|
+
collector.add(traceId);
|
|
205
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
206
|
+
if (!headers.has("traceparent"))
|
|
207
|
+
headers.set("traceparent", makeTraceparent(traceId, spanId));
|
|
208
|
+
return orig(input, { ...init, headers });
|
|
209
|
+
});
|
|
210
|
+
return () => { window.fetch = orig; };
|
|
211
|
+
}
|
package/dist/workers.d.ts
CHANGED
|
@@ -197,9 +197,9 @@ export declare function __resetWarnings(): void;
|
|
|
197
197
|
* a Worker never drops a handler Cloudflare requires for deploy.
|
|
198
198
|
*/
|
|
199
199
|
interface WorkerHandler<E> {
|
|
200
|
-
fetch
|
|
201
|
-
queue
|
|
202
|
-
scheduled
|
|
200
|
+
fetch?(req: Request, env: E, ctx: ExecutionContext): Promise<Response> | Response;
|
|
201
|
+
queue?(batch: MessageBatch<any>, env: E, ctx: ExecutionContext): Promise<void> | void;
|
|
202
|
+
scheduled?(controller: ScheduledController, env: E, ctx: ExecutionContext): Promise<void> | void;
|
|
203
203
|
[key: string]: unknown;
|
|
204
204
|
}
|
|
205
205
|
/**
|
package/package.json
CHANGED
|
@@ -1,26 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heystack/otel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Runtime-aware OpenTelemetry tracing that exports to Heystack (Node, Next.js, Workers).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/core.js",
|
|
8
8
|
"types": "./dist/core.d.ts",
|
|
9
9
|
"exports": {
|
|
10
|
-
".": {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/core.d.ts",
|
|
12
|
+
"import": "./dist/core.js"
|
|
13
|
+
},
|
|
14
|
+
"./node": {
|
|
15
|
+
"types": "./dist/node.d.ts",
|
|
16
|
+
"import": "./dist/node.js"
|
|
17
|
+
},
|
|
18
|
+
"./next": {
|
|
19
|
+
"types": "./dist/next.d.ts",
|
|
20
|
+
"import": "./dist/next.js"
|
|
21
|
+
},
|
|
22
|
+
"./workers": {
|
|
23
|
+
"types": "./dist/workers.d.ts",
|
|
24
|
+
"import": "./dist/workers.js"
|
|
25
|
+
},
|
|
26
|
+
"./web": {
|
|
27
|
+
"types": "./dist/web.d.ts",
|
|
28
|
+
"import": "./dist/web.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
14
37
|
},
|
|
15
|
-
"files": ["dist", "README.md"],
|
|
16
|
-
"publishConfig": { "access": "public" },
|
|
17
38
|
"scripts": {
|
|
18
39
|
"build": "tsc -p tsconfig.build.json",
|
|
19
40
|
"test": "vitest run",
|
|
20
41
|
"typecheck": "tsc --noEmit",
|
|
21
42
|
"validate": "node scripts/validate-entries.mjs",
|
|
22
|
-
"
|
|
23
|
-
"
|
|
43
|
+
"consumer-typecheck": "node scripts/consumer-typecheck.mjs",
|
|
44
|
+
"check": "pnpm typecheck && pnpm build && pnpm validate && pnpm consumer-typecheck && pnpm test",
|
|
45
|
+
"prepublishOnly": "pnpm build && pnpm validate && pnpm consumer-typecheck"
|
|
24
46
|
},
|
|
25
47
|
"dependencies": {
|
|
26
48
|
"@opentelemetry/api": "^1.9.0",
|
|
@@ -30,7 +52,8 @@
|
|
|
30
52
|
"@opentelemetry/auto-instrumentations-node": "^0.55.0",
|
|
31
53
|
"@opentelemetry/sdk-trace-base": "^1.30.0",
|
|
32
54
|
"@opentelemetry/resources": "^1.30.0",
|
|
33
|
-
"@opentelemetry/semantic-conventions": "^1.30.0"
|
|
55
|
+
"@opentelemetry/semantic-conventions": "^1.30.0",
|
|
56
|
+
"rrweb": "^2.0.0-alpha.18"
|
|
34
57
|
},
|
|
35
58
|
"devDependencies": {
|
|
36
59
|
"vitest": "^2.1.0",
|