@heystack/otel 0.4.3 → 0.5.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 +80 -9
- package/dist/workers-bindings.d.ts +33 -0
- package/dist/workers-bindings.js +205 -0
- package/dist/workers.d.ts +81 -7
- package/dist/workers.js +355 -43
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,32 +85,102 @@ export default instrument(
|
|
|
85
85
|
return new Response("ok");
|
|
86
86
|
},
|
|
87
87
|
},
|
|
88
|
-
{
|
|
88
|
+
{
|
|
89
|
+
service: "my-worker", // apiKey defaults to env.HEYSTACK_API_KEY
|
|
90
|
+
getUser: (req) => ({
|
|
91
|
+
id: req.headers.get("x-user-id") ?? undefined,
|
|
92
|
+
}),
|
|
93
|
+
instrumentBindings: true, // auto-trace D1/KV/R2/Vectorize
|
|
94
|
+
},
|
|
89
95
|
);
|
|
90
96
|
```
|
|
91
97
|
|
|
92
|
-
|
|
98
|
+
`instrument()` must be the **outermost** wrapper if other middleware also wraps the handler, so the request span covers everything inside:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
export default instrument(withOtherMiddleware(worker), { service: "my-worker" });
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Set the key as a secret: `wrangler secret put HEYSTACK_API_KEY`.
|
|
93
105
|
|
|
94
|
-
> **Requires `nodejs_compat` on workerd.**
|
|
106
|
+
> **Requires `nodejs_compat` on workerd.** The SDK uses `AsyncLocalStorage` for per-request context isolation. Add to `wrangler.toml`:
|
|
95
107
|
> ```toml
|
|
96
108
|
> compatibility_flags = ["nodejs_compat"]
|
|
97
109
|
> ```
|
|
98
|
-
>
|
|
110
|
+
> Without it the SDK falls back to a synchronous stack-based context manager — suppression still works, but cross-`await` span parenting and per-request isolation degrade to best-effort.
|
|
99
111
|
|
|
100
|
-
###
|
|
112
|
+
### `WorkersConfig` options
|
|
101
113
|
|
|
102
|
-
|
|
114
|
+
| Option | Type | Notes |
|
|
115
|
+
| --- | --- | --- |
|
|
116
|
+
| `service` | `string` | **Required.** Service name that appears in the Heystack console. |
|
|
117
|
+
| `apiKey` | `string?` | Defaults to `env.HEYSTACK_API_KEY`. |
|
|
118
|
+
| `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). |
|
|
119
|
+
| `instrumentBindings` | `boolean \| string[]` | `true` = auto child spans for all detected D1/KV/R2/Vectorize bindings; `string[]` = only the named bindings. Default `false`. |
|
|
120
|
+
| `waitUntil` | `(p: Promise<unknown>) => void` | Override the isolate keep-alive hook; defaults to the auto-detected `ctx.waitUntil`. |
|
|
121
|
+
| `endpoint` | `string?` | Override the ingest endpoint (advanced). |
|
|
103
122
|
|
|
104
|
-
|
|
123
|
+
### Automatic tracing
|
|
124
|
+
|
|
125
|
+
`instrument()` traces the following automatically, with no additional config:
|
|
126
|
+
|
|
127
|
+
- **Incoming requests (`fetch`)** — a SERVER span per request, carrying `http.request.method`, `url.full`, `url.path`, `server.address`, `http.response.status_code`, and `enduser.id`/`client.address`/geo attributes (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.
|
|
128
|
+
- **Outbound `fetch`** — each outbound subrequest while a request span is active gets a CLIENT child span (`http.request.method`, `url.full`, `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.
|
|
129
|
+
- **Queue consumers (`queue`)** — a CONSUMER span per batch, with `messaging.destination.name` (queue name) and `messaging.batch.message_count`.
|
|
130
|
+
- **Scheduled handlers (`scheduled`)** — an INTERNAL span per invocation, with `controller.cron`.
|
|
131
|
+
- **Binding calls** (when `instrumentBindings` is set) — a child span for every D1 query (`db.statement`), KV read/write, R2 operation, and Vectorize query.
|
|
132
|
+
|
|
133
|
+
### Client enrichment
|
|
134
|
+
|
|
135
|
+
These attributes are set automatically on every SERVER span from request metadata:
|
|
136
|
+
|
|
137
|
+
| Attribute | Source |
|
|
138
|
+
| --- | --- |
|
|
139
|
+
| `enduser.id` | `getUser(req).id` |
|
|
140
|
+
| `session.id` | `getUser(req).session` |
|
|
141
|
+
| `http.request.id` | `getUser(req).requestId` or `cf-ray` header |
|
|
142
|
+
| `client.address` | `CF-Connecting-IP` header |
|
|
143
|
+
| `geo.country`, `geo.region`, `geo.city`, `geo.asn` | Cloudflare `req.cf` object |
|
|
144
|
+
|
|
145
|
+
### Manual spans: `withSpan` / `addEvent`
|
|
146
|
+
|
|
147
|
+
Inside a traced handler, add finer-grained spans without touching the OpenTelemetry API directly:
|
|
105
148
|
|
|
106
149
|
```ts
|
|
107
|
-
|
|
150
|
+
import { instrument, withSpan, addEvent } from "@heystack/otel/workers";
|
|
151
|
+
|
|
152
|
+
// Inside a fetch handler:
|
|
153
|
+
const result = await withSpan("parse-payload", { "source": "body" }, async (span) => {
|
|
154
|
+
addEvent("parsing-started");
|
|
155
|
+
span.setAttribute("content-type", req.headers.get("content-type") ?? "");
|
|
156
|
+
return JSON.parse(await req.text());
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Without attrs:
|
|
160
|
+
const data = await withSpan("call-llm", async () => {
|
|
161
|
+
return callMyAI();
|
|
162
|
+
});
|
|
108
163
|
```
|
|
109
164
|
|
|
110
|
-
|
|
165
|
+
`withSpan(name, attrs?, fn)` — runs `fn` inside a new child span parented to the currently-active span. The span is started before `fn` and ended in `finally`; exceptions are recorded on the span and re-thrown. The `fn` receives the live `Span` as its argument.
|
|
166
|
+
|
|
167
|
+
`addEvent(name, attrs?)` — adds a named event to the currently-active span. No-op when no span is active.
|
|
168
|
+
|
|
169
|
+
### Why a ContextManager
|
|
170
|
+
|
|
171
|
+
`context.with(...)` in OpenTelemetry is a **no-op unless a ContextManager is registered** with the global API. Without one, `suppressTracing()` — the primary defence against the self-trace feedback loop — silently does nothing in production. As of **0.3.2** the SDK registers a ContextManager exactly once at init. With `AsyncLocalStorage` (workerd under `nodejs_compat`, Node) you also get **cross-`await` parent→child span linking** and **per-request context isolation** — concurrent requests no longer share or clobber the active span.
|
|
111
172
|
|
|
112
173
|
As of **0.3.1** `instrument()` **forwards every other handler your Worker exports** — `queue`, `scheduled`, `tail`, etc. — untouched, so wrapping never drops a handler Cloudflare requires for deploy (it previously returned only `{ fetch }`, which broke Queue/Cron Workers). On top of forwarding, `queue` and `scheduled` are themselves traced when present: each gets a root span via the global tracer (`queue <queueName>` as a CONSUMER span with batch attributes; `scheduled <cron>` as an INTERNAL span with the cron attribute), flushed via `ctx.waitUntil` just like `fetch`.
|
|
113
174
|
|
|
175
|
+
### Streaming responses & trace correlation (`/workers`)
|
|
176
|
+
|
|
177
|
+
`instrument()` keeps the SERVER span open until the response body finishes
|
|
178
|
+
streaming, so a streamed response's duration includes time-to-last-byte and the
|
|
179
|
+
span carries a `first_byte` event (time-to-first-byte). It also continues an
|
|
180
|
+
inbound W3C `traceparent` (so a client request and the server handler share one
|
|
181
|
+
trace) and returns a `traceparent` response header — plus
|
|
182
|
+
`Access-Control-Expose-Headers: traceparent` so browser clients can read it.
|
|
183
|
+
|
|
114
184
|
### Durable Objects are NOT covered by `instrument()`
|
|
115
185
|
|
|
116
186
|
`instrument()` wraps the keys of the **default-export handler object** (`fetch`/`queue`/`scheduled`/… ). **Durable Objects are separate named class exports**, so spreading the handler object does not touch them — a DO's `fetch`/`alarm` methods run **untraced** even when your Worker's default export is wrapped.
|
|
@@ -204,6 +274,7 @@ As belt-and-suspenders the exporter also drops any span whose HTTP target points
|
|
|
204
274
|
|
|
205
275
|
## Migration / versioning
|
|
206
276
|
|
|
277
|
+
- **`0.5.0`** — **`/workers`: identity enrichment, binding tracing, outbound-fetch tracing, manual span helpers.** New `WorkersConfig` options: `getUser` (attach `enduser.id`/`session.id`/`http.request.id` per request from a synchronous callback), `instrumentBindings` (auto child spans for D1/KV/R2/Vectorize — `true` = all detected, or a `string[]` to select). Outbound `fetch` calls made inside a traced handler automatically get CLIENT child spans with `traceparent` injection (distributed tracing across services). New ergonomic exports from `/workers`: `withSpan(name, attrs?, fn)` runs a function inside a named child span (auto-parented, exceptions recorded, `span.end()` in `finally`); `addEvent(name, attrs?)` adds an event to the active span. No breaking changes; all new options are optional.
|
|
207
278
|
- **`0.4.3`** — **feedback-loop guard extended to the Node path (cost fix).** The self-instrumentation loop the Workers path fixed in 0.3.1/0.3.2 was still live on plain Node / Next-on-Node: the OTLP-over-`http` exporter's `POST /v1/traces` was auto-instrumented and re-exported, so ~77% of ingested spans in production were the exporter tracing itself — needless ingest + ClickHouse compute. 0.4.3 ignores ingest-host requests in the HTTP/undici auto-instrumentations **and** filters self-spans at the exporter boundary (covers caller-supplied `instrumentations` too). The hostname matcher is now a shared module used by both `/node` and `/workers`. No API change. **Action: upgrade and redeploy any Node/Next-on-Node app** — it cuts ingested span volume sharply.
|
|
208
279
|
- **`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.
|
|
209
280
|
- **`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.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Span } from "@opentelemetry/api";
|
|
2
|
+
export interface InstrumentBindingsOpts {
|
|
3
|
+
/**
|
|
4
|
+
* Factory that creates and starts a new child span. Called at binding method
|
|
5
|
+
* invocation time (inside the traced handler scope), so `context.active()`
|
|
6
|
+
* at that moment correctly parents to the root span.
|
|
7
|
+
*
|
|
8
|
+
* For integration: pass `(name, attrs) => tracer.startSpan(name, { attributes: attrs }, context.active())`.
|
|
9
|
+
* For unit tests: inject a fake so no global provider is required.
|
|
10
|
+
*/
|
|
11
|
+
startSpan: (name: string, attrs: Record<string, unknown>) => Span;
|
|
12
|
+
/**
|
|
13
|
+
* `true` → auto-detect and wrap all D1/KV/R2/Vectorize bindings.
|
|
14
|
+
* `string[]` → only wrap bindings whose env key is listed.
|
|
15
|
+
*/
|
|
16
|
+
select: boolean | string[];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Wrap an env object's Cloudflare bindings so that each binding operation
|
|
20
|
+
* emits a child span under the currently-active OTel context.
|
|
21
|
+
*
|
|
22
|
+
* Detects binding type by duck-typing (D1: `prepare`; KV: `get`+`put`+`list`;
|
|
23
|
+
* R2: `get`+`put`+`head`; Vectorize: `query`+`upsert`). Unrecognised bindings
|
|
24
|
+
* are passed through unchanged.
|
|
25
|
+
*
|
|
26
|
+
* Each wrapped binding is a `Proxy` over the original — non-wrapped prototype
|
|
27
|
+
* methods fall through to the real binding so no functionality is lost.
|
|
28
|
+
*
|
|
29
|
+
* @param env - The Worker env / binding bag.
|
|
30
|
+
* @param opts - `startSpan` factory + `select` filter.
|
|
31
|
+
* @returns A shallow copy of `env` with selected bindings replaced by proxies.
|
|
32
|
+
*/
|
|
33
|
+
export declare function instrumentEnv<E extends Record<string, unknown>>(env: E, opts: InstrumentBindingsOpts): E;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Cloudflare binding instrumentation for @heystack/otel/workers.
|
|
3
|
+
//
|
|
4
|
+
// Wraps D1, KV, R2, and Vectorize bindings with OTel child spans so that
|
|
5
|
+
// every binding operation is visible as a child of the active request span.
|
|
6
|
+
//
|
|
7
|
+
// WinterCG-safe: no `node:*` imports. Span factory is injected so the logic
|
|
8
|
+
// is pure and unit-testable without a global provider.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
import { context, SpanStatusCode } from "@opentelemetry/api";
|
|
11
|
+
import { isTracingSuppressed } from "@opentelemetry/core";
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Duck-type detectors — conservative; require the distinctive method set
|
|
14
|
+
// exactly as documented in the task brief.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
function isD1Like(b) {
|
|
17
|
+
return typeof b?.prepare === "function";
|
|
18
|
+
}
|
|
19
|
+
/** R2 is checked BEFORE KV because R2 also exposes `list`. */
|
|
20
|
+
function isR2Like(b) {
|
|
21
|
+
return (typeof b?.get === "function" &&
|
|
22
|
+
typeof b?.put === "function" &&
|
|
23
|
+
typeof b?.head === "function");
|
|
24
|
+
}
|
|
25
|
+
function isKVLike(b) {
|
|
26
|
+
return (typeof b?.get === "function" &&
|
|
27
|
+
typeof b?.put === "function" &&
|
|
28
|
+
typeof b?.list === "function");
|
|
29
|
+
}
|
|
30
|
+
function isVectorizeLike(b) {
|
|
31
|
+
return (typeof b?.query === "function" &&
|
|
32
|
+
typeof b?.upsert === "function");
|
|
33
|
+
}
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Span lifecycle helper
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
async function runWithSpan(span, fn) {
|
|
38
|
+
try {
|
|
39
|
+
return await fn();
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
span.recordException(err instanceof Error ? err : new Error(String(err)));
|
|
43
|
+
span.setStatus({
|
|
44
|
+
code: SpanStatusCode.ERROR,
|
|
45
|
+
message: err instanceof Error ? err.message : String(err),
|
|
46
|
+
});
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
span.end();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Proxy helper — intercepts listed handlers, falls through to prototype for rest
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
function makeProxy(target, handlers) {
|
|
57
|
+
return new Proxy(target, {
|
|
58
|
+
get(t, prop, receiver) {
|
|
59
|
+
if (typeof prop === "string" && prop in handlers)
|
|
60
|
+
return handlers[prop];
|
|
61
|
+
const val = Reflect.get(t, prop, receiver);
|
|
62
|
+
return typeof val === "function" ? val.bind(t) : val;
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// D1 wrappers
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
function wrapD1Statement(stmt, sql, opts) {
|
|
70
|
+
const wrapOp = (op) => async (...args) => {
|
|
71
|
+
if (isTracingSuppressed(context.active())) {
|
|
72
|
+
return stmt[op](...args);
|
|
73
|
+
}
|
|
74
|
+
const span = opts.startSpan(`D1 ${op}`, {
|
|
75
|
+
"db.system": "d1",
|
|
76
|
+
"db.statement": sql,
|
|
77
|
+
});
|
|
78
|
+
return runWithSpan(span, () => stmt[op](...args));
|
|
79
|
+
};
|
|
80
|
+
return makeProxy(stmt, {
|
|
81
|
+
all: wrapOp("all"),
|
|
82
|
+
first: wrapOp("first"),
|
|
83
|
+
run: wrapOp("run"),
|
|
84
|
+
raw: wrapOp("raw"),
|
|
85
|
+
bind(...bindArgs) {
|
|
86
|
+
// Return a wrapped statement so the sql propagates through bind chains.
|
|
87
|
+
return wrapD1Statement(stmt.bind(...bindArgs), sql, opts);
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function wrapD1(binding, opts) {
|
|
92
|
+
return makeProxy(binding, {
|
|
93
|
+
prepare(sql) {
|
|
94
|
+
return wrapD1Statement(binding.prepare(sql), sql, opts);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// KV wrappers
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
function wrapKV(binding, opts, namespace) {
|
|
102
|
+
const wrapOp = (op, getKey) => async (...args) => {
|
|
103
|
+
if (isTracingSuppressed(context.active())) {
|
|
104
|
+
return binding[op](...args);
|
|
105
|
+
}
|
|
106
|
+
const attrs = { "kv.namespace": namespace };
|
|
107
|
+
const key = getKey?.(...args);
|
|
108
|
+
if (key !== undefined)
|
|
109
|
+
attrs["kv.key"] = key;
|
|
110
|
+
const span = opts.startSpan(`KV ${op}`, attrs);
|
|
111
|
+
return runWithSpan(span, () => binding[op](...args));
|
|
112
|
+
};
|
|
113
|
+
return makeProxy(binding, {
|
|
114
|
+
get: wrapOp("get", (key) => key),
|
|
115
|
+
put: wrapOp("put", (key) => key),
|
|
116
|
+
list: wrapOp("list"),
|
|
117
|
+
delete: wrapOp("delete", (key) => key),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// R2 wrappers
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
function wrapR2(binding, opts, bucket) {
|
|
124
|
+
const wrapOp = (op, getKey) => async (...args) => {
|
|
125
|
+
if (isTracingSuppressed(context.active())) {
|
|
126
|
+
return binding[op](...args);
|
|
127
|
+
}
|
|
128
|
+
const attrs = { "r2.bucket": bucket };
|
|
129
|
+
const key = getKey?.(...args);
|
|
130
|
+
if (key !== undefined)
|
|
131
|
+
attrs["r2.key"] = key;
|
|
132
|
+
const span = opts.startSpan(`R2 ${op}`, attrs);
|
|
133
|
+
return runWithSpan(span, () => binding[op](...args));
|
|
134
|
+
};
|
|
135
|
+
return makeProxy(binding, {
|
|
136
|
+
get: wrapOp("get", (key) => key),
|
|
137
|
+
put: wrapOp("put", (key) => key),
|
|
138
|
+
head: wrapOp("head", (key) => key),
|
|
139
|
+
delete: wrapOp("delete", (key) => key),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Vectorize wrappers
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
function wrapVectorize(binding, opts, indexName) {
|
|
146
|
+
const wrapOp = (op) => async (...args) => {
|
|
147
|
+
if (isTracingSuppressed(context.active())) {
|
|
148
|
+
return binding[op](...args);
|
|
149
|
+
}
|
|
150
|
+
const span = opts.startSpan(`Vectorize ${op}`, {
|
|
151
|
+
"vectorize.index": indexName,
|
|
152
|
+
});
|
|
153
|
+
return runWithSpan(span, () => binding[op](...args));
|
|
154
|
+
};
|
|
155
|
+
return makeProxy(binding, {
|
|
156
|
+
query: wrapOp("query"),
|
|
157
|
+
upsert: wrapOp("upsert"),
|
|
158
|
+
insert: wrapOp("insert"),
|
|
159
|
+
deleteByIds: wrapOp("deleteByIds"),
|
|
160
|
+
getByIds: wrapOp("getByIds"),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Main export
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
/**
|
|
167
|
+
* Wrap an env object's Cloudflare bindings so that each binding operation
|
|
168
|
+
* emits a child span under the currently-active OTel context.
|
|
169
|
+
*
|
|
170
|
+
* Detects binding type by duck-typing (D1: `prepare`; KV: `get`+`put`+`list`;
|
|
171
|
+
* R2: `get`+`put`+`head`; Vectorize: `query`+`upsert`). Unrecognised bindings
|
|
172
|
+
* are passed through unchanged.
|
|
173
|
+
*
|
|
174
|
+
* Each wrapped binding is a `Proxy` over the original — non-wrapped prototype
|
|
175
|
+
* methods fall through to the real binding so no functionality is lost.
|
|
176
|
+
*
|
|
177
|
+
* @param env - The Worker env / binding bag.
|
|
178
|
+
* @param opts - `startSpan` factory + `select` filter.
|
|
179
|
+
* @returns A shallow copy of `env` with selected bindings replaced by proxies.
|
|
180
|
+
*/
|
|
181
|
+
export function instrumentEnv(env, opts) {
|
|
182
|
+
const result = { ...env };
|
|
183
|
+
const { select } = opts;
|
|
184
|
+
for (const key of Object.keys(env)) {
|
|
185
|
+
// Filter: if select is an array, only wrap keys in the list.
|
|
186
|
+
if (select !== true && !select.includes(key))
|
|
187
|
+
continue;
|
|
188
|
+
const binding = env[key];
|
|
189
|
+
if (isD1Like(binding)) {
|
|
190
|
+
result[key] = wrapD1(binding, opts);
|
|
191
|
+
}
|
|
192
|
+
else if (isR2Like(binding)) {
|
|
193
|
+
// R2 before KV — R2 also has `list`, so checking `head` first avoids mis-classifying.
|
|
194
|
+
result[key] = wrapR2(binding, opts, key);
|
|
195
|
+
}
|
|
196
|
+
else if (isKVLike(binding)) {
|
|
197
|
+
result[key] = wrapKV(binding, opts, key);
|
|
198
|
+
}
|
|
199
|
+
else if (isVectorizeLike(binding)) {
|
|
200
|
+
result[key] = wrapVectorize(binding, opts, key);
|
|
201
|
+
}
|
|
202
|
+
// Unrecognised bindings are left as-is.
|
|
203
|
+
}
|
|
204
|
+
return result;
|
|
205
|
+
}
|
package/dist/workers.d.ts
CHANGED
|
@@ -23,6 +23,11 @@ interface OtlpKeyValue {
|
|
|
23
23
|
key: string;
|
|
24
24
|
value: OtlpAnyValue;
|
|
25
25
|
}
|
|
26
|
+
interface OtlpSpanEvent {
|
|
27
|
+
timeUnixNano: string;
|
|
28
|
+
name: string;
|
|
29
|
+
attributes: OtlpKeyValue[];
|
|
30
|
+
}
|
|
26
31
|
interface OtlpSpan {
|
|
27
32
|
traceId: string;
|
|
28
33
|
spanId: string;
|
|
@@ -32,6 +37,7 @@ interface OtlpSpan {
|
|
|
32
37
|
startTimeUnixNano: string;
|
|
33
38
|
endTimeUnixNano: string;
|
|
34
39
|
attributes: OtlpKeyValue[];
|
|
40
|
+
events: OtlpSpanEvent[];
|
|
35
41
|
status: {
|
|
36
42
|
code: number;
|
|
37
43
|
message?: string;
|
|
@@ -54,6 +60,14 @@ interface OtlpTracesPayload {
|
|
|
54
60
|
* hence the same resource, so we emit a single resourceSpans entry.
|
|
55
61
|
*/
|
|
56
62
|
export declare function serializeSpans(spans: ReadableSpan[]): OtlpTracesPayload;
|
|
63
|
+
/** Parse a W3C `traceparent`. Returns null for malformed or all-zero ids. */
|
|
64
|
+
export declare function parseTraceparent(header: string | null): {
|
|
65
|
+
traceId: string;
|
|
66
|
+
spanId: string;
|
|
67
|
+
traceFlags: number;
|
|
68
|
+
} | null;
|
|
69
|
+
/** Add `value` to Access-Control-Expose-Headers without duplicating it. */
|
|
70
|
+
export declare function appendExposeHeader(headers: Headers, value: string): void;
|
|
57
71
|
/**
|
|
58
72
|
* Test-only helper: run the self-span attribute check directly against a plain
|
|
59
73
|
* attribute bag + ingest hostname, without constructing a ReadableSpan. The
|
|
@@ -61,6 +75,12 @@ export declare function serializeSpans(spans: ReadableSpan[]): OtlpTracesPayload
|
|
|
61
75
|
* the exporter derives via `safeHostname(cfg.url)`.
|
|
62
76
|
*/
|
|
63
77
|
export declare function isSelfSpanForTest(attrs: Record<string, unknown>, ingestHost: string): boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Reset the outbound-fetch instrumentation: restore the captured platform fetch
|
|
80
|
+
* (only when our wrapper is still the installed global) and clear the guard.
|
|
81
|
+
* Internal/testing helper.
|
|
82
|
+
*/
|
|
83
|
+
export declare function __resetFetchInstrumentation(): void;
|
|
64
84
|
/**
|
|
65
85
|
* A WinterCG-compatible OTLP/JSON span exporter. POSTs ended spans to the
|
|
66
86
|
* Heystack ingest using the platform `fetch` — no Node built-ins.
|
|
@@ -68,7 +88,7 @@ export declare function isSelfSpanForTest(attrs: Record<string, unknown>, ingest
|
|
|
68
88
|
export declare class HeystackSpanExporter implements SpanExporter {
|
|
69
89
|
private readonly url;
|
|
70
90
|
/** Hostname (no port) of the ingest endpoint, used to drop self-trace spans. */
|
|
71
|
-
|
|
91
|
+
readonly ingestHost: string;
|
|
72
92
|
private readonly headers;
|
|
73
93
|
private shutdownState;
|
|
74
94
|
/**
|
|
@@ -122,6 +142,23 @@ export interface WorkersConfig {
|
|
|
122
142
|
* automatically borrows the request context's `ctx.waitUntil`.
|
|
123
143
|
*/
|
|
124
144
|
waitUntil?: (p: Promise<unknown>) => void;
|
|
145
|
+
/**
|
|
146
|
+
* Optional hook called on each incoming request to supply identity context.
|
|
147
|
+
* Return `{ id }` to tag the SERVER span with `enduser.id`, `{ session }` for
|
|
148
|
+
* `session.id`, or `{ requestId }` to override `http.request.id` (otherwise
|
|
149
|
+
* the `cf-ray` request header is used as a fallback). Any field may be omitted.
|
|
150
|
+
*/
|
|
151
|
+
getUser?: (req: Request) => {
|
|
152
|
+
id?: string;
|
|
153
|
+
session?: string;
|
|
154
|
+
requestId?: string;
|
|
155
|
+
} | undefined;
|
|
156
|
+
/**
|
|
157
|
+
* Declare which bindings to instrument with tracing (Task 5). Pass `true` to
|
|
158
|
+
* trace all bindings, or an array of binding names to trace selectively.
|
|
159
|
+
* Defaults to `false` (no binding tracing). Consumed by a later task.
|
|
160
|
+
*/
|
|
161
|
+
instrumentBindings?: boolean | string[];
|
|
125
162
|
}
|
|
126
163
|
/**
|
|
127
164
|
* A `BasicTracerProvider` with the underlying `HeystackSpanExporter` attached so
|
|
@@ -143,12 +180,31 @@ export type HeystackTracerProvider = BasicTracerProvider & {
|
|
|
143
180
|
*/
|
|
144
181
|
export declare function createTracerProvider(config: HeystackOptions): HeystackTracerProvider;
|
|
145
182
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
183
|
+
* An AsyncLocalStorage-backed ContextManager for the /workers entry. When the
|
|
184
|
+
* runtime exposes `globalThis.AsyncLocalStorage` (workerd and Node do), this
|
|
185
|
+
* manager is preferred over `SyncStackContextManager` because it propagates
|
|
186
|
+
* context across `await` boundaries — child spans created after an `await`
|
|
187
|
+
* inside a handler are correctly parented to the request's root span.
|
|
188
|
+
*
|
|
189
|
+
* WinterCG-safe: it does NOT import `node:async_hooks`; instead it uses the
|
|
190
|
+
* ALS global that the runtime exposes. Falls back to `SyncStackContextManager`
|
|
191
|
+
* when the global is absent (Deno, Bun without globals, etc.).
|
|
192
|
+
*/
|
|
193
|
+
export declare class AlsContextManager implements ContextManager {
|
|
194
|
+
private _als;
|
|
195
|
+
constructor();
|
|
196
|
+
active(): Context;
|
|
197
|
+
with<A extends unknown[], F extends (...args: A) => ReturnType<F>>(ctx: Context, fn: F, thisArg?: ThisParameterType<F>, ...args: A): ReturnType<F>;
|
|
198
|
+
bind<T>(_ctx: Context, target: T): T;
|
|
199
|
+
enable(): this;
|
|
200
|
+
disable(): this;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* A minimal SYNCHRONOUS, stack-based ContextManager — the fallback for the
|
|
204
|
+
* /workers entry when `globalThis.AsyncLocalStorage` is absent (any WinterCG
|
|
205
|
+
* runtime without the global). It makes `context.with()` propagate
|
|
206
|
+
* synchronously, which is enough for the exporter's `suppressTracing` to take
|
|
207
|
+
* effect — but it does NOT carry context across `await` boundaries.
|
|
152
208
|
*/
|
|
153
209
|
export declare class SyncStackContextManager implements ContextManager {
|
|
154
210
|
private _stack;
|
|
@@ -269,4 +325,22 @@ type EnvOf<H> = H extends {
|
|
|
269
325
|
scheduled: (controller: ScheduledController, env: infer E, ctx: ExecutionContext) => unknown;
|
|
270
326
|
} ? E : unknown;
|
|
271
327
|
export declare function instrument<H extends WorkerHandler<any>>(handler: H, config: WorkersConfig): Instrumented<EnvOf<H>, H>;
|
|
328
|
+
/**
|
|
329
|
+
* Run `fn` inside a new child span named `name`. The span is automatically
|
|
330
|
+
* parented to the currently-active span (via `context.active()`), started
|
|
331
|
+
* before `fn` and ended in `finally` — so the caller never needs to call
|
|
332
|
+
* `span.end()`. If `fn` throws, the exception is recorded on the span and
|
|
333
|
+
* the status is set to ERROR before re-throwing.
|
|
334
|
+
*
|
|
335
|
+
* Two call signatures are supported:
|
|
336
|
+
* withSpan("name", async (span) => { ... })
|
|
337
|
+
* withSpan("name", { attr: "value" }, async (span) => { ... })
|
|
338
|
+
*/
|
|
339
|
+
export declare function withSpan<T>(name: string, fn: (span: Span) => T | Promise<T>): Promise<T>;
|
|
340
|
+
export declare function withSpan<T>(name: string, attrs: Record<string, string | number | boolean>, fn: (span: Span) => T | Promise<T>): Promise<T>;
|
|
341
|
+
/**
|
|
342
|
+
* Add a named event (with optional attributes) to the currently-active span.
|
|
343
|
+
* No-op when no span is active.
|
|
344
|
+
*/
|
|
345
|
+
export declare function addEvent(name: string, attrs?: Record<string, string | number | boolean>): void;
|
|
272
346
|
export type { Span };
|
package/dist/workers.js
CHANGED
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
// ships its own OTLP/JSON-over-fetch span exporter so it runs on Workers/Edge
|
|
9
9
|
// where the Node SDK cannot.
|
|
10
10
|
import { context, trace, SpanKind, SpanStatusCode, } from "@opentelemetry/api";
|
|
11
|
-
import { suppressTracing } from "@opentelemetry/core";
|
|
11
|
+
import { suppressTracing, isTracingSuppressed } from "@opentelemetry/core";
|
|
12
12
|
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
16
|
import { buildExporterConfig } from "./core.js";
|
|
17
17
|
import { isSelfSpanAttrs, safeHostname } from "./self-span.js";
|
|
18
|
+
import { instrumentEnv } from "./workers-bindings.js";
|
|
18
19
|
// `ExportResult` / `ExportResultCode` mirror `@opentelemetry/core`. We define
|
|
19
20
|
// them inline (structurally identical) rather than import them: core is only a
|
|
20
21
|
// transitive dep of sdk-trace-base and isn't reliably resolvable, and keeping it
|
|
@@ -116,6 +117,11 @@ function readableSpanToOtlp(span) {
|
|
|
116
117
|
startTimeUnixNano: hrTimeToUnixNano(span.startTime),
|
|
117
118
|
endTimeUnixNano: hrTimeToUnixNano(span.endTime),
|
|
118
119
|
attributes: toKeyValues(span.attributes),
|
|
120
|
+
events: span.events.map((ev) => ({
|
|
121
|
+
timeUnixNano: hrTimeToUnixNano(ev.time),
|
|
122
|
+
name: ev.name,
|
|
123
|
+
attributes: toKeyValues((ev.attributes ?? {})),
|
|
124
|
+
})),
|
|
119
125
|
status: toStatus(span.status),
|
|
120
126
|
};
|
|
121
127
|
}
|
|
@@ -139,6 +145,36 @@ export function serializeSpans(spans) {
|
|
|
139
145
|
};
|
|
140
146
|
}
|
|
141
147
|
// ---------------------------------------------------------------------------
|
|
148
|
+
// W3C traceparent + CORS expose-header helpers (FR5 tap→server correlation)
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
const TRACEPARENT_RE = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
|
151
|
+
/** Parse a W3C `traceparent`. Returns null for malformed or all-zero ids. */
|
|
152
|
+
export function parseTraceparent(header) {
|
|
153
|
+
if (!header)
|
|
154
|
+
return null;
|
|
155
|
+
const m = TRACEPARENT_RE.exec(header.trim());
|
|
156
|
+
if (!m)
|
|
157
|
+
return null;
|
|
158
|
+
const traceId = m[1];
|
|
159
|
+
const spanId = m[2];
|
|
160
|
+
const flags = m[3];
|
|
161
|
+
if (traceId === "0".repeat(32) || spanId === "0".repeat(16))
|
|
162
|
+
return null;
|
|
163
|
+
return { traceId, spanId, traceFlags: parseInt(flags, 16) };
|
|
164
|
+
}
|
|
165
|
+
/** Add `value` to Access-Control-Expose-Headers without duplicating it. */
|
|
166
|
+
export function appendExposeHeader(headers, value) {
|
|
167
|
+
const existing = headers.get("access-control-expose-headers");
|
|
168
|
+
if (!existing) {
|
|
169
|
+
headers.set("access-control-expose-headers", value);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const present = existing.split(",").map((s) => s.trim().toLowerCase());
|
|
173
|
+
if (!present.includes(value.toLowerCase())) {
|
|
174
|
+
headers.set("access-control-expose-headers", `${existing}, ${value}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
142
178
|
// Self-span filtering (feedback-loop guard)
|
|
143
179
|
//
|
|
144
180
|
// On Next/OpenNext the host auto-instruments outbound `fetch`, so the exporter's
|
|
@@ -164,6 +200,143 @@ export function isSelfSpanForTest(attrs, ingestHost) {
|
|
|
164
200
|
return isSelfSpanAttrs(attrs, ingestHost);
|
|
165
201
|
}
|
|
166
202
|
// ---------------------------------------------------------------------------
|
|
203
|
+
// Outbound fetch auto-instrumentation (CLIENT child spans + traceparent)
|
|
204
|
+
//
|
|
205
|
+
// Patches globalThis.fetch ONCE (guarded), capturing the platform fetch the
|
|
206
|
+
// instant before replacing it. Each outbound call made inside a traced request
|
|
207
|
+
// (an active span is present, tracing is not suppressed, and the target is not
|
|
208
|
+
// the ingest host) gets a CLIENT child span AND a W3C `traceparent` injected
|
|
209
|
+
// into a CLONE of the request — so a downstream Heystack-instrumented service
|
|
210
|
+
// continues the SAME trace (distributed tracing).
|
|
211
|
+
//
|
|
212
|
+
// Why patch-once + ALS rather than a per-request global swap: under workerd a
|
|
213
|
+
// single isolate serves concurrent requests, so swapping globalThis.fetch per
|
|
214
|
+
// request (install/restore in a finally) would race across overlapping requests.
|
|
215
|
+
// Task 2's AsyncLocalStorage context manager makes `context.active()` reflect the
|
|
216
|
+
// CURRENT request's active span across awaits, so ONE global wrapper can decide
|
|
217
|
+
// per-call which request (if any) the subrequest belongs to.
|
|
218
|
+
//
|
|
219
|
+
// `_originalFetch` is the captured platform fetch. The exporter's own POST uses
|
|
220
|
+
// it directly (see `export()`), so an export is never re-entered by this wrapper
|
|
221
|
+
// (belt; the wrapper also bails on suppressed contexts + ingest-host targets —
|
|
222
|
+
// suspenders). Reading `safeHostname` / the self-span host concept keeps the
|
|
223
|
+
// self-span guardrail satisfied and the path WinterCG-safe (pure string logic).
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
let _fetchInstrumented = false;
|
|
226
|
+
let _originalFetch;
|
|
227
|
+
let _fetchWrapper;
|
|
228
|
+
/** Resolve the absolute URL of an outbound fetch arg (string | URL | Request). */
|
|
229
|
+
function outboundUrl(input) {
|
|
230
|
+
if (typeof input === "string")
|
|
231
|
+
return input;
|
|
232
|
+
if (input instanceof URL)
|
|
233
|
+
return input.href;
|
|
234
|
+
if (input instanceof Request)
|
|
235
|
+
return input.url;
|
|
236
|
+
try {
|
|
237
|
+
return String(input.url ?? input);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return "";
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/** Resolve the HTTP method of an outbound fetch arg. */
|
|
244
|
+
function outboundMethod(input, init) {
|
|
245
|
+
if (init?.method)
|
|
246
|
+
return init.method;
|
|
247
|
+
if (input instanceof Request)
|
|
248
|
+
return input.method;
|
|
249
|
+
return "GET";
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Return an [input, init] pair with `traceparent` injected, WITHOUT mutating the
|
|
253
|
+
* caller's Request/Headers. For a Request input we build a fresh Request copying
|
|
254
|
+
* it; for string/URL we clone the init headers.
|
|
255
|
+
*/
|
|
256
|
+
function injectTraceparent(input, init, traceparent) {
|
|
257
|
+
if (input instanceof Request) {
|
|
258
|
+
const headers = new Headers(init?.headers ?? input.headers);
|
|
259
|
+
headers.set("traceparent", traceparent);
|
|
260
|
+
return [new Request(input, { ...(init ?? {}), headers }), undefined];
|
|
261
|
+
}
|
|
262
|
+
const headers = new Headers(init?.headers);
|
|
263
|
+
headers.set("traceparent", traceparent);
|
|
264
|
+
return [input, { ...(init ?? {}), headers }];
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Patch `globalThis.fetch` exactly once to emit a CLIENT child span + inject
|
|
268
|
+
* `traceparent` for outbound subrequests. `ingestHost` is the bare ingest
|
|
269
|
+
* hostname (lower-case, no port) so the exporter's own uploads are never traced.
|
|
270
|
+
*/
|
|
271
|
+
function ensureFetchInstrumentation(ingestHost) {
|
|
272
|
+
if (_fetchInstrumented)
|
|
273
|
+
return;
|
|
274
|
+
_fetchInstrumented = true;
|
|
275
|
+
// Capture the platform fetch the instant before replacing it (cost guardrail:
|
|
276
|
+
// "capture original before patching"). The exporter reuses this captured
|
|
277
|
+
// reference, so its POST is never routed back through the wrapper.
|
|
278
|
+
const originalFetch = globalThis.fetch.bind(globalThis);
|
|
279
|
+
_originalFetch = originalFetch;
|
|
280
|
+
const wrapper = async (input, init) => {
|
|
281
|
+
const active = context.active();
|
|
282
|
+
// Passthrough UNCHANGED when there is nothing to parent to (no active span,
|
|
283
|
+
// e.g. a fetch outside a traced request) or tracing is suppressed (the
|
|
284
|
+
// exporter's own POST, or anything under suppressTracing).
|
|
285
|
+
if (isTracingSuppressed(active) || !trace.getSpan(active)) {
|
|
286
|
+
return originalFetch(input, init);
|
|
287
|
+
}
|
|
288
|
+
const target = outboundUrl(input);
|
|
289
|
+
const host = safeHostname(target);
|
|
290
|
+
// No parseable host, or the ingest host itself (self-span) → don't trace.
|
|
291
|
+
if (!host || host === ingestHost) {
|
|
292
|
+
return originalFetch(input, init);
|
|
293
|
+
}
|
|
294
|
+
const method = outboundMethod(input, init);
|
|
295
|
+
const span = trace.getTracer("heystack").startSpan(`${method} ${host}`, {
|
|
296
|
+
kind: SpanKind.CLIENT,
|
|
297
|
+
attributes: {
|
|
298
|
+
"http.request.method": method,
|
|
299
|
+
"url.full": target,
|
|
300
|
+
"server.address": host,
|
|
301
|
+
},
|
|
302
|
+
}, active);
|
|
303
|
+
const sc = span.spanContext();
|
|
304
|
+
const traceparent = `00-${sc.traceId}-${sc.spanId}-01`;
|
|
305
|
+
const [reqInput, reqInit] = injectTraceparent(input, init, traceparent);
|
|
306
|
+
try {
|
|
307
|
+
const response = await originalFetch(reqInput, reqInit);
|
|
308
|
+
span.setAttribute("http.response.status_code", response.status);
|
|
309
|
+
return response;
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
span.recordException(error instanceof Error ? error : new Error(String(error)));
|
|
313
|
+
span.setStatus({
|
|
314
|
+
code: SpanStatusCode.ERROR,
|
|
315
|
+
message: error instanceof Error ? error.message : String(error),
|
|
316
|
+
});
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
finally {
|
|
320
|
+
span.end();
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
_fetchWrapper = wrapper;
|
|
324
|
+
globalThis.fetch = _fetchWrapper;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Reset the outbound-fetch instrumentation: restore the captured platform fetch
|
|
328
|
+
* (only when our wrapper is still the installed global) and clear the guard.
|
|
329
|
+
* Internal/testing helper.
|
|
330
|
+
*/
|
|
331
|
+
export function __resetFetchInstrumentation() {
|
|
332
|
+
if (_fetchWrapper && globalThis.fetch === _fetchWrapper && _originalFetch) {
|
|
333
|
+
globalThis.fetch = _originalFetch;
|
|
334
|
+
}
|
|
335
|
+
_fetchInstrumented = false;
|
|
336
|
+
_originalFetch = undefined;
|
|
337
|
+
_fetchWrapper = undefined;
|
|
338
|
+
}
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
167
340
|
// Exporter
|
|
168
341
|
// ---------------------------------------------------------------------------
|
|
169
342
|
/**
|
|
@@ -231,9 +404,12 @@ export class HeystackSpanExporter {
|
|
|
231
404
|
// The POST runs inside a tracing-suppressed context so that host fetch
|
|
232
405
|
// auto-instrumentation (e.g. Next/OpenNext) does NOT create a CLIENT span
|
|
233
406
|
// for it — which would otherwise be exported and re-captured, a sustained
|
|
234
|
-
// feedback loop.
|
|
407
|
+
// feedback loop. As a belt-and-suspenders second layer it also uses the
|
|
408
|
+
// CAPTURED platform fetch (when our outbound-fetch wrapper is installed), so
|
|
409
|
+
// the export can never be re-entered by that wrapper regardless of context.
|
|
410
|
+
const doFetch = _originalFetch ?? fetch;
|
|
235
411
|
const p = context
|
|
236
|
-
.with(suppressTracing(context.active()), () =>
|
|
412
|
+
.with(suppressTracing(context.active()), () => doFetch(this.url, { method: "POST", headers: this.headers, body }))
|
|
237
413
|
.then((res) => {
|
|
238
414
|
if (res.ok) {
|
|
239
415
|
resultCallback({ code: ExportResultCode.SUCCESS });
|
|
@@ -355,31 +531,51 @@ export function createTracerProvider(config) {
|
|
|
355
531
|
// Global tracer provider registration (for host frameworks, e.g. Next.js)
|
|
356
532
|
// ---------------------------------------------------------------------------
|
|
357
533
|
let _provider = null;
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
// global OTel API. Without one, `suppressTracing(context.active())` produces a
|
|
363
|
-
// context that is never made active, so the exporter's POST is NOT suppressed
|
|
364
|
-
// in production and host fetch auto-instrumentation can re-trace it (feedback
|
|
365
|
-
// loop). We therefore register a manager exactly ONCE in `ensureGlobalProvider`.
|
|
366
|
-
//
|
|
367
|
-
// We register a dependency-free SYNCHRONOUS stack manager (below). Deliberately
|
|
368
|
-
// NOT AsyncLocalStorageContextManager: that statically imports `node:async_hooks`,
|
|
369
|
-
// which would break `import "@heystack/otel/workers"` on a bare workerd without
|
|
370
|
-
// `nodejs_compat` (and on other WinterCG runtimes) — defeating the whole point of
|
|
371
|
-
// this entry being node-builtin-free. The sync manager covers the critical path:
|
|
372
|
-
// the exporter's POST runs synchronously inside the suppressed `context.with`, so
|
|
373
|
-
// `suppressTracing` takes effect. Trade-off: no cross-`await` context propagation,
|
|
374
|
-
// so deep nested-span parenting is limited on the edge (documented).
|
|
375
|
-
// ---------------------------------------------------------------------------
|
|
534
|
+
/** Read the ALS global lazily — at call time, not module load time. */
|
|
535
|
+
function getALS() {
|
|
536
|
+
return globalThis.AsyncLocalStorage;
|
|
537
|
+
}
|
|
376
538
|
/**
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
539
|
+
* An AsyncLocalStorage-backed ContextManager for the /workers entry. When the
|
|
540
|
+
* runtime exposes `globalThis.AsyncLocalStorage` (workerd and Node do), this
|
|
541
|
+
* manager is preferred over `SyncStackContextManager` because it propagates
|
|
542
|
+
* context across `await` boundaries — child spans created after an `await`
|
|
543
|
+
* inside a handler are correctly parented to the request's root span.
|
|
544
|
+
*
|
|
545
|
+
* WinterCG-safe: it does NOT import `node:async_hooks`; instead it uses the
|
|
546
|
+
* ALS global that the runtime exposes. Falls back to `SyncStackContextManager`
|
|
547
|
+
* when the global is absent (Deno, Bun without globals, etc.).
|
|
548
|
+
*/
|
|
549
|
+
export class AlsContextManager {
|
|
550
|
+
_als;
|
|
551
|
+
constructor() {
|
|
552
|
+
const ALS = getALS();
|
|
553
|
+
if (!ALS)
|
|
554
|
+
throw new Error("AlsContextManager: globalThis.AsyncLocalStorage is not available in this runtime");
|
|
555
|
+
this._als = new ALS();
|
|
556
|
+
}
|
|
557
|
+
active() {
|
|
558
|
+
return this._als.getStore() ?? ROOT_CONTEXT;
|
|
559
|
+
}
|
|
560
|
+
with(ctx, fn, thisArg, ...args) {
|
|
561
|
+
return this._als.run(ctx, () => fn.call(thisArg, ...args));
|
|
562
|
+
}
|
|
563
|
+
bind(_ctx, target) {
|
|
564
|
+
return target;
|
|
565
|
+
}
|
|
566
|
+
enable() {
|
|
567
|
+
return this;
|
|
568
|
+
}
|
|
569
|
+
disable() {
|
|
570
|
+
return this;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* A minimal SYNCHRONOUS, stack-based ContextManager — the fallback for the
|
|
575
|
+
* /workers entry when `globalThis.AsyncLocalStorage` is absent (any WinterCG
|
|
576
|
+
* runtime without the global). It makes `context.with()` propagate
|
|
577
|
+
* synchronously, which is enough for the exporter's `suppressTracing` to take
|
|
578
|
+
* effect — but it does NOT carry context across `await` boundaries.
|
|
383
579
|
*/
|
|
384
580
|
export class SyncStackContextManager {
|
|
385
581
|
_stack = [];
|
|
@@ -412,20 +608,19 @@ let _contextManagerRegistered = false;
|
|
|
412
608
|
* `context.with(suppressTracing(...))` in the exporter is actually honoured —
|
|
413
609
|
* otherwise suppression is a no-op and the exporter's POST can be re-traced.
|
|
414
610
|
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
* `await` boundaries
|
|
421
|
-
* acceptable, documented limitation (workerd has no async context manager by
|
|
422
|
-
* default regardless).
|
|
611
|
+
* When `globalThis.AsyncLocalStorage` is available (workerd, Node), we register
|
|
612
|
+
* an `AlsContextManager` that propagates context across `await` boundaries —
|
|
613
|
+
* child spans created after an `await` are correctly parented to the root span.
|
|
614
|
+
* When absent we fall back to `SyncStackContextManager`, which covers the
|
|
615
|
+
* critical suppression path synchronously but does not carry context across
|
|
616
|
+
* `await` boundaries.
|
|
423
617
|
*/
|
|
424
618
|
function ensureContextManager() {
|
|
425
619
|
if (_contextManagerRegistered)
|
|
426
620
|
return;
|
|
427
621
|
_contextManagerRegistered = true;
|
|
428
|
-
|
|
622
|
+
const mgr = getALS() ? new AlsContextManager() : new SyncStackContextManager();
|
|
623
|
+
context.setGlobalContextManager(mgr.enable());
|
|
429
624
|
}
|
|
430
625
|
/** Reset the context-manager registration guard. Internal/testing helper. */
|
|
431
626
|
export function __resetContextManager() {
|
|
@@ -456,6 +651,10 @@ function ensureGlobalProvider(config) {
|
|
|
456
651
|
// the exporter actually takes effect — without one, `context.with` is a no-op
|
|
457
652
|
// and suppression silently does nothing in production.
|
|
458
653
|
ensureContextManager();
|
|
654
|
+
// Patch globalThis.fetch (once) so outbound subrequests get CLIENT child spans
|
|
655
|
+
// + `traceparent` injection (distributed tracing). The exporter's own POST uses
|
|
656
|
+
// the captured original fetch, so it is never re-entered by this wrapper.
|
|
657
|
+
ensureFetchInstrumentation(_provider.heystackExporter.ingestHost);
|
|
459
658
|
return _provider;
|
|
460
659
|
}
|
|
461
660
|
/**
|
|
@@ -562,6 +761,17 @@ export function instrument(handler, config) {
|
|
|
562
761
|
return originalFetch(req, env, ctx);
|
|
563
762
|
const { provider, tracer } = s;
|
|
564
763
|
const url = new URL(req.url);
|
|
764
|
+
// FR5: continue an inbound W3C traceparent so tap→server is one trace.
|
|
765
|
+
const parent = parseTraceparent(req.headers.get("traceparent"));
|
|
766
|
+
let startCtx = context.active();
|
|
767
|
+
if (parent) {
|
|
768
|
+
startCtx = trace.setSpanContext(startCtx, {
|
|
769
|
+
traceId: parent.traceId,
|
|
770
|
+
spanId: parent.spanId,
|
|
771
|
+
traceFlags: parent.traceFlags,
|
|
772
|
+
isRemote: true,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
565
775
|
const span = tracer.startSpan(`${req.method} ${url.pathname}`, {
|
|
566
776
|
kind: SpanKind.SERVER,
|
|
567
777
|
attributes: {
|
|
@@ -570,16 +780,92 @@ export function instrument(handler, config) {
|
|
|
570
780
|
"url.path": url.pathname,
|
|
571
781
|
"server.address": url.host,
|
|
572
782
|
},
|
|
573
|
-
});
|
|
783
|
+
}, startCtx);
|
|
784
|
+
// A1 enrichment: identity, request id, client ip, geo.
|
|
785
|
+
// All attributes are set only when non-empty to keep spans lean.
|
|
786
|
+
const userInfo = config.getUser?.(req);
|
|
787
|
+
if (userInfo?.id)
|
|
788
|
+
span.setAttribute("enduser.id", userInfo.id);
|
|
789
|
+
if (userInfo?.session)
|
|
790
|
+
span.setAttribute("session.id", userInfo.session);
|
|
791
|
+
const reqId = userInfo?.requestId ?? req.headers.get("cf-ray") ?? "";
|
|
792
|
+
if (reqId)
|
|
793
|
+
span.setAttribute("http.request.id", reqId);
|
|
794
|
+
const clientIp = req.headers.get("CF-Connecting-IP") ?? "";
|
|
795
|
+
if (clientIp)
|
|
796
|
+
span.setAttribute("client.address", clientIp);
|
|
797
|
+
// Cloudflare geo — only present on the real CF runtime; read defensively.
|
|
798
|
+
const cf = req.cf;
|
|
799
|
+
if (cf) {
|
|
800
|
+
if (typeof cf.country === "string" && cf.country)
|
|
801
|
+
span.setAttribute("geo.country", cf.country);
|
|
802
|
+
if (typeof cf.region === "string" && cf.region)
|
|
803
|
+
span.setAttribute("geo.region", cf.region);
|
|
804
|
+
if (typeof cf.city === "string" && cf.city)
|
|
805
|
+
span.setAttribute("geo.city", cf.city);
|
|
806
|
+
if (cf.asn != null)
|
|
807
|
+
span.setAttribute("geo.asn", String(cf.asn));
|
|
808
|
+
}
|
|
809
|
+
// FR5: response header carrying THIS span's trace + span id.
|
|
810
|
+
const sc = span.spanContext();
|
|
811
|
+
const traceparent = `00-${sc.traceId}-${sc.spanId}-01`;
|
|
812
|
+
// Instrument bindings when requested — wrap env BEFORE handing to the
|
|
813
|
+
// handler so binding calls made inside `originalFetch` (which runs inside
|
|
814
|
+
// `context.with` below) correctly parent to the root span via ALS.
|
|
815
|
+
let handlerEnv = env;
|
|
816
|
+
if (config.instrumentBindings) {
|
|
817
|
+
const binTracer = trace.getTracer("heystack");
|
|
818
|
+
handlerEnv = instrumentEnv(env, {
|
|
819
|
+
startSpan: (name, attrs) => binTracer.startSpan(name, { attributes: attrs }, context.active()),
|
|
820
|
+
select: config.instrumentBindings,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
574
823
|
try {
|
|
575
|
-
const response = await context.with(trace.setSpan(
|
|
824
|
+
const response = await context.with(trace.setSpan(startCtx, span), () => originalFetch(req, handlerEnv, ctx));
|
|
825
|
+
const headers = new Headers(response.headers);
|
|
826
|
+
headers.set("traceparent", traceparent);
|
|
827
|
+
appendExposeHeader(headers, "traceparent");
|
|
576
828
|
span.setAttribute("http.response.status_code", response.status);
|
|
577
|
-
|
|
578
|
-
|
|
829
|
+
const finalize = () => {
|
|
830
|
+
span.setStatus({
|
|
831
|
+
code: response.status >= 500 ? SpanStatusCode.ERROR : SpanStatusCode.UNSET,
|
|
832
|
+
});
|
|
833
|
+
span.end();
|
|
834
|
+
drain(provider, ctx);
|
|
835
|
+
};
|
|
836
|
+
// No body to stream → finalize now (redirects, 204/304, etc.).
|
|
837
|
+
if (!response.body) {
|
|
838
|
+
finalize();
|
|
839
|
+
return new Response(null, {
|
|
840
|
+
status: response.status,
|
|
841
|
+
statusText: response.statusText,
|
|
842
|
+
headers,
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
// FR1: keep the span open until the streamed body drains; the first
|
|
846
|
+
// chunk records time-to-first-byte. `finished` guards double-finalize.
|
|
847
|
+
let firstByte = false;
|
|
848
|
+
let finished = false;
|
|
849
|
+
const monitor = new TransformStream({
|
|
850
|
+
transform(chunk, controller) {
|
|
851
|
+
if (!firstByte) {
|
|
852
|
+
firstByte = true;
|
|
853
|
+
span.addEvent("first_byte");
|
|
854
|
+
}
|
|
855
|
+
controller.enqueue(chunk);
|
|
856
|
+
},
|
|
857
|
+
flush() {
|
|
858
|
+
if (finished)
|
|
859
|
+
return;
|
|
860
|
+
finished = true;
|
|
861
|
+
finalize();
|
|
862
|
+
},
|
|
863
|
+
});
|
|
864
|
+
return new Response(response.body.pipeThrough(monitor), {
|
|
865
|
+
status: response.status,
|
|
866
|
+
statusText: response.statusText,
|
|
867
|
+
headers,
|
|
579
868
|
});
|
|
580
|
-
span.end();
|
|
581
|
-
drain(provider, ctx);
|
|
582
|
-
return response;
|
|
583
869
|
}
|
|
584
870
|
catch (error) {
|
|
585
871
|
span.recordException(error instanceof Error ? error : new Error(String(error)));
|
|
@@ -658,3 +944,29 @@ export function instrument(handler, config) {
|
|
|
658
944
|
}
|
|
659
945
|
return wrapped;
|
|
660
946
|
}
|
|
947
|
+
export async function withSpan(name, attrsOrFn, maybeFn) {
|
|
948
|
+
const attrs = typeof attrsOrFn === "function" ? undefined : attrsOrFn;
|
|
949
|
+
const fn = (typeof attrsOrFn === "function" ? attrsOrFn : maybeFn);
|
|
950
|
+
const tracer = trace.getTracer("heystack");
|
|
951
|
+
const span = tracer.startSpan(name, attrs ? { attributes: attrs } : undefined);
|
|
952
|
+
const ctx = trace.setSpan(context.active(), span);
|
|
953
|
+
try {
|
|
954
|
+
return await context.with(ctx, () => fn(span));
|
|
955
|
+
}
|
|
956
|
+
catch (err) {
|
|
957
|
+
span.recordException(err);
|
|
958
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
|
|
959
|
+
throw err;
|
|
960
|
+
}
|
|
961
|
+
finally {
|
|
962
|
+
span.end();
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Add a named event (with optional attributes) to the currently-active span.
|
|
967
|
+
* No-op when no span is active.
|
|
968
|
+
*/
|
|
969
|
+
export function addEvent(name, attrs) {
|
|
970
|
+
const span = trace.getSpan(context.active());
|
|
971
|
+
span?.addEvent(name, attrs);
|
|
972
|
+
}
|