@heystack/otel 0.17.0 → 0.19.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/dist/workers.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // `@opentelemetry/resources` and the platform `fetch` — all WinterCG-safe — and
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
- import { context, createContextKey, INVALID_SPAN_CONTEXT, trace, SpanKind, SpanStatusCode, } from "@opentelemetry/api";
10
+ import { context, createContextKey, trace, SpanKind, SpanStatusCode, } from "@opentelemetry/api";
11
11
  import { suppressTracing, isTracingSuppressed } from "@opentelemetry/core";
12
12
  import { ROOT_CONTEXT } from "@opentelemetry/api";
13
13
  import { resourceFromAttributes } from "@opentelemetry/resources";
@@ -16,9 +16,14 @@ import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
16
16
  import { buildExporterConfig, DEFAULT_ENDPOINT, releaseResourceAttributes, } from "./core.js";
17
17
  import { isSelfSpanAttrs, safeHostname } from "./self-span.js";
18
18
  import { llmRequestAttrs, llmResponseAttrs, llmContentAttrs, llmOperationName, resolveLLMProvider, scanSseStreamForUsage, } from "./llm-enrich.js";
19
- import { instrumentEnv, makeProxy, runWithSpan } from "./workers-bindings.js";
19
+ import { instrumentEnv, keysOf, makeProxy, runWithSpan, runWithSpanKeyed, runWithSpanPreservingReturn, settlePendingKeyHashes, wrapSqlClient, } from "./workers-bindings.js";
20
+ import { NATIVELY_COVERED, NATIVE_PROPAGATES_EXTERNAL, shouldEnrichNatively } from "./workers-coverage.js";
20
21
  import { makeSampler, loadRemoteSamplingRate } from "./workers-sampler.js";
22
+ import { createNativePropagation, injectOutboundHeaders, isAiGatewayHost, NATIVE_PROPAGATION_CONTEXT_KEY, propagationTargetAllowed, propagationTraceparent, } from "./workers-propagation.js";
23
+ import { resolveRoute } from "./workers-routes.js";
21
24
  import { makeTraceparent } from "./traceparent.js";
25
+ import { NATIVE_TRACING_CONTEXT_KEY, NativeOtelSpan, getNativeTracing, nativeTracingFromContext, runNativeSpan, setNativeContextInit, } from "./workers-native.js";
26
+ import { applySpanPrivacy, makeUrlRedactor, } from "./redact.js";
22
27
  // `ExportResult` / `ExportResultCode` mirror `@opentelemetry/core`. We define
23
28
  // them inline (structurally identical) rather than import them: core is only a
24
29
  // transitive dep of sdk-trace-base and isn't reliably resolvable, and keeping it
@@ -109,8 +114,9 @@ function toStatus(status) {
109
114
  out.message = status.message;
110
115
  return out;
111
116
  }
112
- function readableSpanToOtlp(span) {
117
+ function readableSpanToOtlp(span, transform) {
113
118
  const ctx = span.spanContext();
119
+ const attrs = span.attributes;
114
120
  return {
115
121
  traceId: ctx.traceId,
116
122
  spanId: ctx.spanId,
@@ -121,7 +127,7 @@ function readableSpanToOtlp(span) {
121
127
  kind: toOtlpKind(span.kind),
122
128
  startTimeUnixNano: hrTimeToUnixNano(span.startTime),
123
129
  endTimeUnixNano: hrTimeToUnixNano(span.endTime),
124
- attributes: toKeyValues(span.attributes),
130
+ attributes: toKeyValues(transform ? transform(attrs, span) : attrs),
125
131
  events: span.events.map((ev) => ({
126
132
  timeUnixNano: hrTimeToUnixNano(ev.time),
127
133
  name: ev.name,
@@ -136,7 +142,7 @@ function readableSpanToOtlp(span) {
136
142
  * All spans in a SimpleSpanProcessor batch share the same per-request provider,
137
143
  * hence the same resource, so we emit a single resourceSpans entry.
138
144
  */
139
- export function serializeSpans(spans) {
145
+ export function serializeSpans(spans, transform) {
140
146
  const resourceAttrs = spans[0]
141
147
  ? spans[0].resource.attributes
142
148
  : {};
@@ -144,7 +150,7 @@ export function serializeSpans(spans) {
144
150
  resourceSpans: [
145
151
  {
146
152
  resource: { attributes: toKeyValues(resourceAttrs) },
147
- scopeSpans: [{ spans: spans.map(readableSpanToOtlp) }],
153
+ scopeSpans: [{ spans: spans.map((s) => readableSpanToOtlp(s, transform)) }],
148
154
  },
149
155
  ],
150
156
  };
@@ -228,6 +234,8 @@ export function isSelfSpanForTest(attrs, ingestHost) {
228
234
  // self-span guardrail satisfied and the path WinterCG-safe (pure string logic).
229
235
  // ---------------------------------------------------------------------------
230
236
  let _fetchInstrumented = false;
237
+ /** URL redactor for outbound CLIENT spans (set from config by ensureGlobalProvider). */
238
+ let _fetchRedact = makeUrlRedactor();
231
239
  let _originalFetch;
232
240
  let _fetchWrapper;
233
241
  /** Resolve the absolute URL of an outbound fetch arg (string | URL | Request). */
@@ -270,12 +278,6 @@ function injectTraceparent(input, init, traceparent) {
270
278
  }
271
279
  /** Max response body size to clone for gen_ai enrichment (512 KB). */
272
280
  const LLM_RESPONSE_SIZE_CAP = 512 * 1024;
273
- /**
274
- * Context key under which `runNativeSpan` / `withSpan` publish the request's
275
- * Cloudflare native tracer, so nested helpers (and the fetch wrapper) can join
276
- * the platform trace tree instead of creating OTel-provider spans.
277
- */
278
- const NATIVE_TRACING_CONTEXT_KEY = createContextKey("heystack.cloudflare.native-tracing");
279
281
  /**
280
282
  * Latest ingest host / ai config seen by `ensureFetchInstrumentation`. The
281
283
  * wrapper is installed ONCE per isolate but `instrument()` may reach it first
@@ -285,6 +287,37 @@ const NATIVE_TRACING_CONTEXT_KEY = createContextKey("heystack.cloudflare.native-
285
287
  */
286
288
  let _fetchIngestHost = "";
287
289
  let _fetchAiCfg = {};
290
+ let _fetchPropagateTargets;
291
+ /** True when the caller already supplied header `name` on this fetch. */
292
+ function hasHeader(input, init, name) {
293
+ try {
294
+ return new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)).has(name);
295
+ }
296
+ catch {
297
+ return false;
298
+ }
299
+ }
300
+ /** Bare ingest hostname for native mode (no exporter exists to derive it from). */
301
+ function nativeIngestHost(config) {
302
+ return safeHostname(config.endpoint ?? DEFAULT_ENDPOINT);
303
+ }
304
+ /**
305
+ * Native root helper: record the inbound parent (only when it validates) and
306
+ * the invocation's synthetic propagation pair as link attributes, then run `fn`
307
+ * with the pair published in OTel context so outbound fetches can use it.
308
+ * These are SDK-minted ids, NOT Cloudflare's native ids (which don't exist).
309
+ */
310
+ function withNativePropagation(span, inboundTraceparent, fn) {
311
+ const prop = createNativePropagation();
312
+ const parent = parseTraceparent(inboundTraceparent);
313
+ if (parent) {
314
+ span.setAttribute("heystack.parent.trace_id", parent.traceId);
315
+ span.setAttribute("heystack.parent.span_id", parent.spanId);
316
+ }
317
+ span.setAttribute("heystack.propagation.trace_id", prop.traceId);
318
+ span.setAttribute("heystack.propagation.span_id", prop.spanId);
319
+ return context.with(context.active().setValue(NATIVE_PROPAGATION_CONTEXT_KEY, prop), fn);
320
+ }
288
321
  /** A promise whose settle handles are exposed. */
289
322
  function deferred() {
290
323
  let resolve;
@@ -395,7 +428,7 @@ async function enrichLLMResponse(span, provider, response, parsedReqBody, aiCfg)
395
428
  * deferred — that is what lets usage tokens land on the span without buffering
396
429
  * or delaying the stream. No OTel provider/exporter is involved.
397
430
  */
398
- function nativeLLMFetch(nativeTracing, resolved, url, input, init, originalFetch, aiCfg) {
431
+ function nativeLLMFetch(nativeTracing, resolved, url, input, init, originalFetch, aiCfg, delegated = false) {
399
432
  const ready = deferred();
400
433
  const name = `${llmOperationName(url.pathname)} ${resolved.provider}`;
401
434
  let run;
@@ -404,7 +437,7 @@ function nativeLLMFetch(nativeTracing, resolved, url, input, init, originalFetch
404
437
  run = nativeTracing.enterSpan(name, async (nativeSpan) => {
405
438
  invoked = true;
406
439
  // Unsampled invocation: skip all enrichment work (no tee, no clone).
407
- if (!nativeSpan.isTraced) {
440
+ if (nativeSpan.isTraced === false) {
408
441
  try {
409
442
  ready.resolve(await originalFetch(input, init));
410
443
  }
@@ -413,9 +446,11 @@ function nativeLLMFetch(nativeTracing, resolved, url, input, init, originalFetch
413
446
  }
414
447
  return;
415
448
  }
416
- const adapter = new NativeSpanAdapter(nativeSpan);
449
+ const adapter = new NativeOtelSpan(nativeSpan);
417
450
  const { attrs, parsedReqBody } = llmRequestSide(resolved, url, init);
418
451
  adapter.setAttributes({ ...attrs, "server.address": url.hostname });
452
+ if (delegated)
453
+ adapter.setAttribute("heystack.ai.delegated", true);
419
454
  try {
420
455
  const response = await originalFetch(input, init);
421
456
  adapter.setAttribute("http.response.status_code", response.status);
@@ -464,10 +499,11 @@ function nativeLLMFetch(nativeTracing, resolved, url, input, init, originalFetch
464
499
  * exporter's own uploads are never traced; native callers pass "" (no exporter).
465
500
  * `aiCfg` configures gen_ai enrichment (content capture is opt-in).
466
501
  */
467
- function ensureFetchInstrumentation(ingestHost, aiCfg = {}) {
502
+ function ensureFetchInstrumentation(ingestHost, aiCfg = {}, propagateTargets) {
468
503
  if (ingestHost)
469
504
  _fetchIngestHost = ingestHost;
470
505
  _fetchAiCfg = aiCfg;
506
+ _fetchPropagateTargets = propagateTargets;
471
507
  if (_fetchInstrumented)
472
508
  return;
473
509
  _fetchInstrumented = true;
@@ -487,10 +523,27 @@ function ensureFetchInstrumentation(ingestHost, aiCfg = {}) {
487
523
  const nativeTracing = active.getValue(NATIVE_TRACING_CONTEXT_KEY);
488
524
  if (nativeTracing) {
489
525
  const url = parseTarget(input);
490
- const resolved = url ? resolveLLMProvider(url, _fetchAiCfg.gateways) : null;
491
- if (!url || !resolved)
526
+ if (!url)
492
527
  return originalFetch(input, init);
493
- return nativeLLMFetch(nativeTracing, resolved, url, input, init, originalFetch, _fetchAiCfg);
528
+ // Link headers: the invocation's synthetic ids, never to the Heystack
529
+ // ingest host and only to hosts allowed by `propagateTargets`.
530
+ let reqInput = input;
531
+ let reqInit = init;
532
+ const prop = active.getValue(NATIVE_PROPAGATION_CONTEXT_KEY);
533
+ const host = url.hostname.toLowerCase();
534
+ const gateway = isAiGatewayHost(url);
535
+ let injected = false;
536
+ if (prop && host !== _fetchIngestHost && propagationTargetAllowed(host, _fetchPropagateTargets)) {
537
+ injected = gateway && !hasHeader(input, init, "cf-aig-otel-trace-id");
538
+ [reqInput, reqInit] = injectOutboundHeaders(input, init, {
539
+ traceparent: propagationTraceparent(prop),
540
+ aiGateway: gateway ? { traceId: prop.traceId, parentSpanId: prop.spanId } : undefined,
541
+ });
542
+ }
543
+ const resolved = resolveLLMProvider(url, _fetchAiCfg.gateways);
544
+ if (!resolved)
545
+ return originalFetch(reqInput, reqInit);
546
+ return nativeLLMFetch(nativeTracing, resolved, url, reqInput, reqInit, originalFetch, _fetchAiCfg, injected && _fetchAiCfg.gatewayExport === true);
494
547
  }
495
548
  // Direct plane: nothing to parent to (a fetch outside a traced request).
496
549
  if (!trace.getSpan(active)) {
@@ -507,13 +560,27 @@ function ensureFetchInstrumentation(ingestHost, aiCfg = {}) {
507
560
  kind: SpanKind.CLIENT,
508
561
  attributes: {
509
562
  "http.request.method": method,
510
- "url.full": target,
563
+ // Redacted: userinfo stripped + sensitive query/fragment values
564
+ // (e.g. Gemini `?key=`, Supabase `?apikey=`) replaced with REDACTED.
565
+ "url.full": _fetchRedact(target),
511
566
  "server.address": host,
512
567
  },
513
568
  }, active);
514
569
  const sc = span.spanContext();
515
570
  const traceparent = makeTraceparent(sc);
516
- const [reqInput, reqInit] = injectTraceparent(input, init, traceparent);
571
+ let [reqInput, reqInit] = injectTraceparent(input, init, traceparent);
572
+ const directUrl = parseTarget(input);
573
+ const directGateway = directUrl !== null && isAiGatewayHost(directUrl);
574
+ if (directGateway) {
575
+ const ours = !hasHeader(input, init, "cf-aig-otel-trace-id");
576
+ // The span's REAL trace id + this CLIENT span id; existing headers win.
577
+ [reqInput, reqInit] = injectOutboundHeaders(reqInput, reqInit, {
578
+ traceparent,
579
+ aiGateway: { traceId: sc.traceId, parentSpanId: sc.spanId },
580
+ });
581
+ if (ours && _fetchAiCfg.gatewayExport === true)
582
+ span.setAttribute("heystack.ai.delegated", true);
583
+ }
517
584
  // gen_ai enrichment — request side (direct host or configured gateway).
518
585
  const url = parseTarget(input);
519
586
  const resolved = url ? resolveLLMProvider(url, _fetchAiCfg.gateways) : null;
@@ -568,6 +635,7 @@ export function __resetFetchInstrumentation() {
568
635
  _fetchWrapper = undefined;
569
636
  _fetchIngestHost = "";
570
637
  _fetchAiCfg = {};
638
+ _fetchRedact = makeUrlRedactor();
571
639
  }
572
640
  // ---------------------------------------------------------------------------
573
641
  // Exporter
@@ -630,6 +698,7 @@ function markManualFlushObserved() {
630
698
  * completes, letting the isolate be torn down and silently dropping the trace.
631
699
  */
632
700
  async function drainProvider(provider) {
701
+ await settlePendingKeyHashes();
633
702
  await provider.forceFlush().catch(() => { });
634
703
  await provider.heystackExporter.forceFlush().catch(() => { });
635
704
  }
@@ -681,8 +750,18 @@ export class HeystackSpanExporter {
681
750
  * the `pending` set + manual `flushHeystack()` when absent/unavailable.
682
751
  */
683
752
  waitUntil;
753
+ /**
754
+ * Privacy pass on every exported span — redacts URL attributes (`url.full`,
755
+ * `http.url`, `http.target`, `url.query`, …) so spans from host frameworks
756
+ * (Next/OpenNext, manual spans) are covered too, not just Heystack's own.
757
+ */
758
+ privacy;
684
759
  constructor(options) {
685
760
  const cfg = buildExporterConfig(options);
761
+ const redact = makeUrlRedactor(options);
762
+ // Client IP is gated at its source (instrument()'s SERVER span), so the
763
+ // exporter only redacts URLs and leaves deliberate manual attributes alone.
764
+ this.privacy = (attrs) => applySpanPrivacy(attrs, { redact, captureClientIp: true });
686
765
  this.url = cfg.url;
687
766
  this.ingestHost = safeHostname(cfg.url);
688
767
  this.headers = {
@@ -710,7 +789,7 @@ export class HeystackSpanExporter {
710
789
  resultCallback({ code: ExportResultCode.SUCCESS });
711
790
  return;
712
791
  }
713
- const body = JSON.stringify(serializeSpans(exportable));
792
+ const body = JSON.stringify(serializeSpans(exportable, this.privacy));
714
793
  // Build the export chain as a promise we retain, so forceFlush() can await
715
794
  // the actual network write — INCLUDING the bounded retry/backoff, so a
716
795
  // waitUntil drain keeps the isolate alive until retries settle too. It
@@ -891,87 +970,31 @@ export class HeystackSpanExporter {
891
970
  }
892
971
  }
893
972
  }
894
- function nativeTracingFromContext() {
895
- return context.active().getValue(NATIVE_TRACING_CONTEXT_KEY);
896
- }
897
973
  /**
898
- * Adapts Cloudflare's deliberately-small Span API to the OTel API shape used by
899
- * `withSpan`. Unsupported OTel operations degrade to attributes instead of
900
- * creating a second exporter/provider.
974
+ * Native mode: wrap the env's uncovered bindings (Workers AI, Vectorize,
975
+ * Workflows, Analytics Engine) so their calls become native child spans. An
976
+ * explicit `instrumentBindings` wins over the coverage gate.
901
977
  */
902
- class NativeSpanAdapter {
903
- nativeSpan;
904
- constructor(nativeSpan) {
905
- this.nativeSpan = nativeSpan;
906
- }
907
- spanContext() { return INVALID_SPAN_CONTEXT; }
908
- setAttribute(key, value) {
909
- try {
910
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
911
- this.nativeSpan.setAttribute(key, value);
912
- }
913
- else if (Array.isArray(value)) {
914
- this.nativeSpan.setAttribute(key, JSON.stringify(value));
915
- }
916
- }
917
- catch {
918
- // Telemetry must never surface as an application error (e.g. a platform
919
- // span rejecting a value); drop the attribute instead.
920
- }
921
- return this;
922
- }
923
- setAttributes(attributes) {
924
- for (const [key, value] of Object.entries(attributes)) {
925
- if (value !== undefined)
926
- this.setAttribute(key, value);
927
- }
928
- return this;
929
- }
930
- addEvent(name, attributesOrStartTime) {
931
- const prefix = `event.${name}`;
932
- this.nativeSpan.setAttribute(prefix, true);
933
- if (attributesOrStartTime && !Array.isArray(attributesOrStartTime) && typeof attributesOrStartTime === "object") {
934
- for (const [key, value] of Object.entries(attributesOrStartTime)) {
935
- if (value !== undefined)
936
- this.setAttribute(`${prefix}.${key}`, value);
937
- }
938
- }
939
- return this;
940
- }
941
- addLink(_link) { return this; }
942
- addLinks(_links) { return this; }
943
- /** True once `recordException` stamped a specific `error.type` (the exception class). */
944
- errorTyped = false;
945
- setStatus(status) {
946
- if (status.code === SpanStatusCode.ERROR) {
947
- // Don't clobber the more specific class name a preceding recordException set.
948
- if (!this.errorTyped)
949
- this.nativeSpan.setAttribute("error.type", "error");
950
- if (status.message)
951
- this.nativeSpan.setAttribute("error.message", status.message);
952
- }
953
- return this;
954
- }
955
- updateName(_name) { return this; }
956
- end(_endTime) { }
957
- isRecording() { return this.nativeSpan.isTraced; }
958
- recordException(exception, _time) {
959
- const error = exception instanceof Error ? exception : new Error(String(exception));
960
- this.nativeSpan.setAttribute("error.type", error.name || "Error");
961
- this.errorTyped = true;
962
- this.nativeSpan.setAttribute("error.message", error.message);
963
- if (error.stack)
964
- this.nativeSpan.setAttribute("exception.stacktrace", error.stack);
965
- }
966
- }
967
- function getNativeTracing(ctx) {
968
- const candidate = ctx.tracing;
969
- return candidate && typeof candidate.enterSpan === "function" ? candidate : undefined;
978
+ function nativeInstrumentedEnv(env, tracing, config) {
979
+ if (!env || typeof env !== "object")
980
+ return env;
981
+ const explicit = config.instrumentBindings;
982
+ if (explicit === false)
983
+ return env;
984
+ return instrumentEnv(env, {
985
+ select: explicit ? explicit : true,
986
+ kindFilter: explicit ? undefined : (k) => shouldEnrichNatively(k, config.nativeEnrichment),
987
+ withSpan: (name, attrs, _kind, fn) => runNativeSpan(tracing, name, attrs, fn),
988
+ nativeNames: true,
989
+ });
970
990
  }
971
991
  function setNativeRequestAttributes(span, config, req) {
972
- const url = new URL(req.url);
973
992
  span.setAttribute("service.name", config.service);
974
- span.setAttribute("http.route", url.pathname);
993
+ // Labels the heystack.request child only; the fetch wrapper also sets the
994
+ // platform invocation span via `getActiveSpan()` when the runtime exposes it.
995
+ const route = resolveRoute(req, config);
996
+ if (route)
997
+ span.setAttribute("http.route", route);
975
998
  if (config.version)
976
999
  span.setAttribute("service.version", config.version);
977
1000
  if (config.build)
@@ -985,26 +1008,6 @@ function setNativeRequestAttributes(span, config, req) {
985
1008
  if (requestId)
986
1009
  span.setAttribute("http.request.id", requestId);
987
1010
  }
988
- async function runNativeSpan(nativeTracing, name, attrs, fn) {
989
- ensureContextManager();
990
- return nativeTracing.enterSpan(name, async (nativeSpan) => {
991
- const adapter = new NativeSpanAdapter(nativeSpan);
992
- for (const [key, value] of Object.entries(attrs)) {
993
- if (value !== undefined)
994
- adapter.setAttribute(key, value);
995
- }
996
- const nativeContext = trace.setSpan(context.active().setValue(NATIVE_TRACING_CONTEXT_KEY, nativeTracing), adapter);
997
- try {
998
- return await context.with(nativeContext, () => fn(adapter));
999
- }
1000
- catch (error) {
1001
- const err = error instanceof Error ? error : new Error(String(error));
1002
- adapter.recordException(err);
1003
- adapter.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
1004
- throw error;
1005
- }
1006
- });
1007
- }
1008
1011
  /**
1009
1012
  * Build a `BasicTracerProvider` wired to a `HeystackSpanExporter` via a
1010
1013
  * `SimpleSpanProcessor`. Exposed for advanced users who want to add manual
@@ -1022,10 +1025,18 @@ export function createTracerProvider(config) {
1022
1025
  // only when the caller supplied `version`/`build` (release attribution).
1023
1026
  resource: resourceFromAttributes({
1024
1027
  [ATTR_SERVICE_NAME]: config.service,
1028
+ // Lets ingest recognise direct-mode Workers apps as Cloudflare (route
1029
+ // templating, native-error and binding inference).
1030
+ "cloud.provider": "cloudflare",
1031
+ "cloud.platform": "cloudflare.workers",
1032
+ // Marks direct-mode SDK spans so coverage never reports a wrapped Worker as native-only.
1033
+ "heystack.sdk.mode": "direct",
1025
1034
  ...releaseResourceAttributes(config),
1026
1035
  }),
1027
1036
  spanProcessors: [new SimpleSpanProcessor(exporter)],
1028
- sampler: makeSampler(config.sampling),
1037
+ sampler: makeSampler(config.sampling, {
1038
+ trustRemoteParent: config.trustInboundSampling !== false,
1039
+ }),
1029
1040
  });
1030
1041
  // Attach the exporter so flush paths can await its in-flight fetches.
1031
1042
  return Object.assign(provider, { heystackExporter: exporter });
@@ -1125,6 +1136,7 @@ function ensureContextManager() {
1125
1136
  const mgr = getALS() ? new AlsContextManager() : new SyncStackContextManager();
1126
1137
  context.setGlobalContextManager(mgr.enable());
1127
1138
  }
1139
+ setNativeContextInit(ensureContextManager);
1128
1140
  /** Reset the context-manager registration guard. Internal/testing helper. */
1129
1141
  export function __resetContextManager() {
1130
1142
  _contextManagerRegistered = false;
@@ -1157,7 +1169,8 @@ function ensureGlobalProvider(config) {
1157
1169
  // Patch globalThis.fetch (once) so outbound subrequests get CLIENT child spans
1158
1170
  // + `traceparent` injection (distributed tracing). The exporter's own POST uses
1159
1171
  // the captured original fetch, so it is never re-entered by this wrapper.
1160
- ensureFetchInstrumentation(_provider.heystackExporter.ingestHost, config.ai ?? {});
1172
+ _fetchRedact = makeUrlRedactor(config);
1173
+ ensureFetchInstrumentation(_provider.heystackExporter.ingestHost, config.ai ?? {}, config.propagateTargets);
1161
1174
  return _provider;
1162
1175
  }
1163
1176
  /**
@@ -1190,6 +1203,13 @@ export async function flushHeystack() {
1190
1203
  markManualFlushObserved();
1191
1204
  if (!_provider)
1192
1205
  return;
1206
+ // Bounded by the same budget as the export drain so a stuck hash can't pin us.
1207
+ let timer;
1208
+ await Promise.race([
1209
+ settlePendingKeyHashes(),
1210
+ new Promise((resolve) => { timer = setTimeout(resolve, DRAIN_TIMEOUT_MS); }),
1211
+ ]).finally(() => { if (timer)
1212
+ clearTimeout(timer); });
1193
1213
  if (typeof _provider.forceFlush === "function") {
1194
1214
  await _provider.forceFlush();
1195
1215
  }
@@ -1289,6 +1309,9 @@ export function instrument(handler, config) {
1289
1309
  build: config.build,
1290
1310
  waitUntil: config.waitUntil,
1291
1311
  sampling: config.sampling,
1312
+ trustInboundSampling: config.trustInboundSampling,
1313
+ redactQueryParams: config.redactQueryParams,
1314
+ captureClientIp: config.captureClientIp,
1292
1315
  ai: config.ai,
1293
1316
  });
1294
1317
  // instrument() wires a per-request drain (see `drain` below), so exports on
@@ -1296,6 +1319,9 @@ export function instrument(handler, config) {
1296
1319
  requestDrainWired = true;
1297
1320
  return { provider, tracer: trace.getTracer("heystack") };
1298
1321
  };
1322
+ // Privacy: one redactor per instrument() call for the inbound SERVER span URL.
1323
+ const redactInboundUrl = makeUrlRedactor(config);
1324
+ const captureGeo = config.captureGeo ?? "country";
1299
1325
  // Drain BOTH the provider's span processor AND the exporter's in-flight fetch
1300
1326
  // via ctx.waitUntil, raced against an ~8s timeout (see drainWithTimeout, #25).
1301
1327
  const drain = (provider, ctx) => {
@@ -1307,7 +1333,7 @@ export function instrument(handler, config) {
1307
1333
  const originalFetch = handler.fetch?.bind(handler);
1308
1334
  if (originalFetch) {
1309
1335
  wrapped.fetch = async (req, env, ctx) => {
1310
- const nativeTracing = config.nativeTracing === "direct" ? undefined : getNativeTracing(ctx);
1336
+ const nativeTracing = config.nativeTracing === "direct" ? null : getNativeTracing(ctx, config.tracing);
1311
1337
  if (nativeTracing) {
1312
1338
  // `withSpan()` discovers the request's native tracer through OTel
1313
1339
  // context. Register our WinterCG-safe manager even though native mode
@@ -1316,14 +1342,25 @@ export function instrument(handler, config) {
1316
1342
  // Install the (LLM-only under native) fetch wrapper so outbound calls
1317
1343
  // to LLM providers/gateways still get gen_ai.* enrichment — native mode
1318
1344
  // never runs `setup()`, which is where the direct plane installs it.
1319
- ensureFetchInstrumentation("", config.ai ?? {});
1345
+ ensureFetchInstrumentation(nativeIngestHost(config), config.ai ?? {}, config.propagateTargets);
1346
+ // The platform's invocation (root fetch) span is what Service Health/RED
1347
+ // read. When the runtime exposes it, put the route template there too;
1348
+ // otherwise `routes` labels only the `heystack.request` child.
1349
+ try {
1350
+ const rootRoute = resolveRoute(req, config);
1351
+ if (rootRoute)
1352
+ nativeTracing.getActiveSpan?.()?.setAttribute("http.route", rootRoute);
1353
+ }
1354
+ catch { /* telemetry must never surface */ }
1320
1355
  return runNativeSpan(nativeTracing, "heystack.request", {}, async (adapter) => {
1321
1356
  setNativeRequestAttributes(adapter, config, req);
1322
- const response = await originalFetch(req, env, ctx);
1323
- adapter.setAttribute("http.response.status_code", response.status);
1324
- if (response.status >= 500)
1325
- adapter.setAttribute("error.type", "http_error");
1326
- return response;
1357
+ return withNativePropagation(adapter, req.headers.get("traceparent"), async () => {
1358
+ const response = await originalFetch(req, nativeInstrumentedEnv(env, nativeTracing, config), ctx);
1359
+ adapter.setAttribute("http.response.status_code", response.status);
1360
+ if (response.status >= 500)
1361
+ adapter.setAttribute("error.type", "http_error");
1362
+ return response;
1363
+ });
1327
1364
  });
1328
1365
  }
1329
1366
  const s = setup(env);
@@ -1362,12 +1399,16 @@ export function instrument(handler, config) {
1362
1399
  kind: SpanKind.SERVER,
1363
1400
  attributes: {
1364
1401
  "http.request.method": req.method,
1365
- "url.full": req.url,
1402
+ "url.full": redactInboundUrl(req.url),
1366
1403
  "url.path": url.pathname,
1367
1404
  "server.address": url.host,
1405
+ "cloudflare.handler_type": "fetch",
1368
1406
  },
1369
1407
  }, startCtx);
1370
- // A1 enrichment: identity, request id, client ip, geo.
1408
+ const directRoute = resolveRoute(req, config);
1409
+ if (directRoute)
1410
+ span.setAttribute("http.route", directRoute);
1411
+ // A1 enrichment: identity, request id, (opt-in) client ip, geo.
1371
1412
  // All attributes are set only when non-empty to keep spans lean.
1372
1413
  const userInfo = config.getUser?.(req);
1373
1414
  if (userInfo?.id)
@@ -1377,20 +1418,26 @@ export function instrument(handler, config) {
1377
1418
  const reqId = userInfo?.requestId ?? req.headers.get("cf-ray") ?? "";
1378
1419
  if (reqId)
1379
1420
  span.setAttribute("http.request.id", reqId);
1380
- const clientIp = req.headers.get("CF-Connecting-IP") ?? "";
1381
- if (clientIp)
1382
- span.setAttribute("client.address", clientIp);
1421
+ // End-user IP is personal data: recorded only with `captureClientIp: true`.
1422
+ if (config.captureClientIp === true) {
1423
+ const clientIp = req.headers.get("CF-Connecting-IP") ?? "";
1424
+ if (clientIp)
1425
+ span.setAttribute("client.address", clientIp);
1426
+ }
1383
1427
  // Cloudflare geo — only present on the real CF runtime; read defensively.
1428
+ // Default is country-only; region/city/ASN need `captureGeo: "full"`.
1384
1429
  const cf = req.cf;
1385
- if (cf) {
1430
+ if (cf && captureGeo) {
1386
1431
  if (typeof cf.country === "string" && cf.country)
1387
1432
  span.setAttribute("geo.country", cf.country);
1388
- if (typeof cf.region === "string" && cf.region)
1389
- span.setAttribute("geo.region", cf.region);
1390
- if (typeof cf.city === "string" && cf.city)
1391
- span.setAttribute("geo.city", cf.city);
1392
- if (cf.asn != null)
1393
- span.setAttribute("geo.asn", String(cf.asn));
1433
+ if (captureGeo === "full") {
1434
+ if (typeof cf.region === "string" && cf.region)
1435
+ span.setAttribute("geo.region", cf.region);
1436
+ if (typeof cf.city === "string" && cf.city)
1437
+ span.setAttribute("geo.city", cf.city);
1438
+ if (cf.asn != null)
1439
+ span.setAttribute("geo.asn", String(cf.asn));
1440
+ }
1394
1441
  }
1395
1442
  // FR5: response header carrying THIS span's trace + span id.
1396
1443
  const sc = span.spanContext();
@@ -1558,19 +1605,21 @@ export function instrument(handler, config) {
1558
1605
  const originalQueue = handler.queue?.bind(handler);
1559
1606
  if (originalQueue) {
1560
1607
  wrapped.queue = async (batch, env, ctx) => {
1561
- const nativeTracing = config.nativeTracing === "direct" ? undefined : getNativeTracing(ctx);
1608
+ const nativeTracing = config.nativeTracing === "direct" ? null : getNativeTracing(ctx, config.tracing);
1562
1609
  if (nativeTracing) {
1563
- ensureFetchInstrumentation("", config.ai ?? {});
1610
+ ensureFetchInstrumentation(nativeIngestHost(config), config.ai ?? {}, config.propagateTargets);
1564
1611
  // Native mode: Cloudflare owns queue spans — pass the ORIGINAL batch
1565
1612
  // through untouched so platform objects keep their identity.
1566
1613
  return runNativeSpan(nativeTracing, `heystack.queue ${batch.queue}`, {
1567
1614
  "service.name": config.service,
1568
1615
  "messaging.batch.message_count": batch.messages.length,
1616
+ "cloudflare.queue.batch_size": batch.messages.length,
1617
+ "cloudflare.handler_type": "queue",
1569
1618
  "messaging.destination.name": batch.queue,
1570
1619
  "messaging.operation.type": "process",
1571
1620
  "service.version": config.version,
1572
1621
  "service.build": config.build,
1573
- }, () => originalQueue(batch, env, ctx));
1622
+ }, (adapter) => withNativePropagation(adapter, null, () => originalQueue(batch, nativeInstrumentedEnv(env, nativeTracing, config), ctx)));
1574
1623
  }
1575
1624
  const s = setup(env);
1576
1625
  if (!s)
@@ -1580,7 +1629,9 @@ export function instrument(handler, config) {
1580
1629
  kind: SpanKind.CONSUMER,
1581
1630
  attributes: {
1582
1631
  "messaging.batch.message_count": batch.messages.length,
1632
+ "cloudflare.queue.batch_size": batch.messages.length,
1583
1633
  "messaging.destination.name": batch.queue,
1634
+ "cloudflare.handler_type": "queue",
1584
1635
  },
1585
1636
  });
1586
1637
  // The request's CONSUMER context, captured per-batch as an explicit parent
@@ -1620,16 +1671,18 @@ export function instrument(handler, config) {
1620
1671
  const originalScheduled = handler.scheduled?.bind(handler);
1621
1672
  if (originalScheduled) {
1622
1673
  wrapped.scheduled = async (controller, env, ctx) => {
1623
- const nativeTracing = config.nativeTracing === "direct" ? undefined : getNativeTracing(ctx);
1674
+ const nativeTracing = config.nativeTracing === "direct" ? null : getNativeTracing(ctx, config.tracing);
1624
1675
  if (nativeTracing) {
1625
- ensureFetchInstrumentation("", config.ai ?? {});
1676
+ ensureFetchInstrumentation(nativeIngestHost(config), config.ai ?? {}, config.propagateTargets);
1626
1677
  return runNativeSpan(nativeTracing, `heystack.scheduled ${controller.cron}`, {
1627
1678
  "service.name": config.service,
1628
1679
  "faas.trigger": "timer",
1629
1680
  "cloudflare.scheduled.cron": controller.cron,
1681
+ "faas.cron": controller.cron,
1682
+ "cloudflare.handler_type": "scheduled",
1630
1683
  "service.version": config.version,
1631
1684
  "service.build": config.build,
1632
- }, () => originalScheduled(controller, env, ctx));
1685
+ }, (adapter) => withNativePropagation(adapter, null, () => originalScheduled(controller, nativeInstrumentedEnv(env, nativeTracing, config), ctx)));
1633
1686
  }
1634
1687
  const s = setup(env);
1635
1688
  if (!s)
@@ -1639,6 +1692,8 @@ export function instrument(handler, config) {
1639
1692
  kind: SpanKind.INTERNAL,
1640
1693
  attributes: {
1641
1694
  "controller.cron": controller.cron,
1695
+ "faas.cron": controller.cron,
1696
+ "cloudflare.handler_type": "scheduled",
1642
1697
  },
1643
1698
  });
1644
1699
  try {
@@ -1662,7 +1717,7 @@ export function instrument(handler, config) {
1662
1717
  const originalEmail = handler.email?.bind(handler);
1663
1718
  if (originalEmail) {
1664
1719
  wrapped.email = async (message, env, ctx) => {
1665
- const nativeTracing = config.nativeTracing === "direct" ? undefined : getNativeTracing(ctx);
1720
+ const nativeTracing = config.nativeTracing === "direct" ? null : getNativeTracing(ctx, config.tracing);
1666
1721
  // PRIVACY: only from/to DOMAINS + envelope size are recorded — never the
1667
1722
  // full addresses and never any message content.
1668
1723
  const { name: emailSpanName, attrs: emailAttrs } = emailHandlerAttrs(message);
@@ -1672,7 +1727,7 @@ export function instrument(handler, config) {
1672
1727
  ...emailAttrs,
1673
1728
  "service.version": config.version,
1674
1729
  "service.build": config.build,
1675
- }, () => originalEmail(message, env, ctx));
1730
+ }, (adapter) => withNativePropagation(adapter, null, () => originalEmail(message, nativeInstrumentedEnv(env, nativeTracing, config), ctx)));
1676
1731
  }
1677
1732
  const s = setup(env);
1678
1733
  if (!s)
@@ -1680,7 +1735,7 @@ export function instrument(handler, config) {
1680
1735
  const { provider, tracer } = s;
1681
1736
  const span = tracer.startSpan(emailSpanName, {
1682
1737
  kind: SpanKind.SERVER,
1683
- attributes: emailAttrs,
1738
+ attributes: { ...emailAttrs, "cloudflare.handler_type": "email" },
1684
1739
  });
1685
1740
  try {
1686
1741
  await context.with(trace.setSpan(context.active(), span), () => originalEmail(message, env, wrapExecutionContextWithProvider(ctx, provider)));
@@ -1703,7 +1758,7 @@ export function instrument(handler, config) {
1703
1758
  const originalTail = handler.tail?.bind(handler);
1704
1759
  if (originalTail) {
1705
1760
  wrapped.tail = async (events, env, ctx) => {
1706
- const nativeTracing = config.nativeTracing === "direct" ? undefined : getNativeTracing(ctx);
1761
+ const nativeTracing = config.nativeTracing === "direct" ? null : getNativeTracing(ctx, config.tracing);
1707
1762
  // PRIVACY: only the event COUNT is recorded — tail payloads can contain
1708
1763
  // request URLs, headers and console output; none of it is captured.
1709
1764
  const count = Array.isArray(events) ? events.length : 0;
@@ -1722,7 +1777,7 @@ export function instrument(handler, config) {
1722
1777
  const { provider, tracer } = s;
1723
1778
  const span = tracer.startSpan("tail", {
1724
1779
  kind: SpanKind.INTERNAL,
1725
- attributes: attrs,
1780
+ attributes: { ...attrs, "cloudflare.handler_type": "tail" },
1726
1781
  });
1727
1782
  try {
1728
1783
  await context.with(trace.setSpan(context.active(), span), () => originalTail(events, env, wrapExecutionContextWithProvider(ctx, provider)));
@@ -1862,7 +1917,18 @@ function classStartSpan(opts) {
1862
1917
  * to the invocation's DurableObjectState `waitUntil`. Best-effort no-op when no
1863
1918
  * state/waitUntil is available (e.g. unit tests).
1864
1919
  */
1865
- function scheduleStateFlush(state) {
1920
+ function scheduleStateFlush(state, invocation) {
1921
+ // SQL cursors the app never finished iterating: close THIS invocation's spans
1922
+ // (without row counts); concurrent invocations on the same DO keep theirs.
1923
+ if (state && typeof state === "object") {
1924
+ const pending = pendingSqlSpans.get(state);
1925
+ if (pending) {
1926
+ for (const p of [...pending]) {
1927
+ if (invocation === undefined || p.owner === undefined || p.owner === invocation)
1928
+ p.abandon();
1929
+ }
1930
+ }
1931
+ }
1866
1932
  const waitUntil = state
1867
1933
  ?.waitUntil;
1868
1934
  if (typeof waitUntil !== "function")
@@ -1874,8 +1940,152 @@ function scheduleStateFlush(state) {
1874
1940
  // A failing waitUntil must never break the wrapped handler.
1875
1941
  }
1876
1942
  }
1877
- /** INTERNAL child spans for DurableObjectStorage get/put/delete/list ops. */
1878
- function wrapDurableObjectStorage(storage, startSpan) {
1943
+ /** Marks the DO handler invocation in context; nested spans inherit it. */
1944
+ const DO_INVOCATION_KEY = createContextKey("heystack.do.invocation");
1945
+ /** Open `sql.exec` spans per raw DurableObjectState, closed when the invocation ends. */
1946
+ const pendingSqlSpans = new WeakMap();
1947
+ /**
1948
+ * `ctx.storage.sql.exec()` → `durable_object_storage_exec`. The cursor is lazy and
1949
+ * owned by the app: we never step it. The span ends (with rows read/written) when
1950
+ * the app completes it via `toArray()`, `one()` or iterator exhaustion, and is
1951
+ * closed without counts if the invocation ends first.
1952
+ */
1953
+ function wrapDurableObjectSql(sql, startSpan, doId, pending) {
1954
+ return new Proxy(sql, {
1955
+ get(t, prop) {
1956
+ const val = Reflect.get(t, prop, t);
1957
+ if (typeof val !== "function")
1958
+ return val;
1959
+ if (prop !== "exec")
1960
+ return val.bind(t);
1961
+ return (query, ...bindings) => {
1962
+ if (isTracingSuppressed(context.active()))
1963
+ return val.call(t, query, ...bindings);
1964
+ const attrs = {};
1965
+ if (typeof query === "string")
1966
+ attrs["db.query.text"] = query;
1967
+ if (doId)
1968
+ attrs["cloudflare.durable_object.id"] = doId;
1969
+ const span = startSpan("durable_object_storage_exec", attrs, SpanKind.INTERNAL);
1970
+ let cursor;
1971
+ try {
1972
+ cursor = val.call(t, query, ...bindings);
1973
+ }
1974
+ catch (error) {
1975
+ span.recordException(error instanceof Error ? error : new Error(String(error)));
1976
+ span.setStatus({
1977
+ code: SpanStatusCode.ERROR,
1978
+ message: error instanceof Error ? error.message : String(error),
1979
+ });
1980
+ span.end();
1981
+ throw error;
1982
+ }
1983
+ if (!cursor || typeof cursor !== "object") {
1984
+ span.end();
1985
+ return cursor;
1986
+ }
1987
+ let done = false;
1988
+ let lastActivity = Date.now();
1989
+ const owner = context.active().getValue(DO_INVOCATION_KEY);
1990
+ const finish = (counts, error) => {
1991
+ if (done)
1992
+ return;
1993
+ done = true;
1994
+ pending.delete(entry);
1995
+ try {
1996
+ if (error !== undefined) {
1997
+ span.recordException(error instanceof Error ? error : new Error(String(error)));
1998
+ span.setStatus({
1999
+ code: SpanStatusCode.ERROR,
2000
+ message: error instanceof Error ? error.message : String(error),
2001
+ });
2002
+ }
2003
+ if (counts) {
2004
+ // Side-effect-free getters: reading them never steps the cursor.
2005
+ if (typeof cursor.rowsRead === "number") {
2006
+ span.setAttribute("cloudflare.durable_object.response.rows_read", cursor.rowsRead);
2007
+ }
2008
+ if (typeof cursor.rowsWritten === "number") {
2009
+ span.setAttribute("cloudflare.durable_object.response.rows_written", cursor.rowsWritten);
2010
+ }
2011
+ span.end();
2012
+ }
2013
+ else {
2014
+ // Abandoned: the work effectively ended at the last observed activity.
2015
+ span.end(lastActivity);
2016
+ }
2017
+ }
2018
+ catch {
2019
+ // Telemetry must never alter the cursor's behaviour.
2020
+ }
2021
+ };
2022
+ const entry = { owner, abandon: () => finish(false) };
2023
+ pending.add(entry);
2024
+ // A statement with no result columns (INSERT/UPDATE/DDL) has fully run
2025
+ // by the time exec() returns; nothing will ever be iterated.
2026
+ if (Array.isArray(cursor.columnNames) && cursor.columnNames.length === 0) {
2027
+ finish(true);
2028
+ return cursor;
2029
+ }
2030
+ const step = (fn, c, a) => {
2031
+ let r;
2032
+ try {
2033
+ r = fn.apply(c, a);
2034
+ }
2035
+ catch (error) {
2036
+ finish(false, error);
2037
+ throw error;
2038
+ }
2039
+ lastActivity = Date.now();
2040
+ if (r && r.done === true)
2041
+ finish(true);
2042
+ return r;
2043
+ };
2044
+ // One observer for the cursor and for raw() iterators: completion is
2045
+ // toArray()/one()/iterator exhaustion; nothing is ever stepped by us.
2046
+ const observe = (target) => {
2047
+ if (!target || typeof target !== "object")
2048
+ return target;
2049
+ const proxy = new Proxy(target, {
2050
+ get(c, p) {
2051
+ const v = Reflect.get(c, p, c);
2052
+ if (typeof v !== "function")
2053
+ return v;
2054
+ if (p === "toArray" || p === "one") {
2055
+ return (...a) => {
2056
+ try {
2057
+ const r = v.apply(c, a);
2058
+ finish(true);
2059
+ return r;
2060
+ }
2061
+ catch (error) {
2062
+ finish(false, error);
2063
+ throw error;
2064
+ }
2065
+ };
2066
+ }
2067
+ if (p === "next")
2068
+ return (...a) => step(v, c, a);
2069
+ if (p === Symbol.iterator) {
2070
+ return () => {
2071
+ const it = v.call(c);
2072
+ return it === c ? proxy : observe(it);
2073
+ };
2074
+ }
2075
+ if (p === "raw")
2076
+ return (...a) => observe(v.apply(c, a));
2077
+ return v.bind(c);
2078
+ },
2079
+ });
2080
+ return proxy;
2081
+ };
2082
+ return observe(cursor);
2083
+ };
2084
+ },
2085
+ });
2086
+ }
2087
+ /** INTERNAL child spans for DurableObjectStorage get/put/delete/list, alarms and SQL. */
2088
+ function wrapDurableObjectStorage(storage, startSpan, doId, pending = new Set()) {
1879
2089
  const wrapOp = (op) => async (...args) => {
1880
2090
  const underlying = storage[op];
1881
2091
  if (typeof underlying !== "function")
@@ -1884,11 +2094,10 @@ function wrapDurableObjectStorage(storage, startSpan) {
1884
2094
  return underlying.apply(storage, args);
1885
2095
  }
1886
2096
  const attrs = {};
2097
+ if (doId)
2098
+ attrs["cloudflare.durable_object.id"] = doId;
1887
2099
  const first = args[0];
1888
- if (typeof first === "string") {
1889
- attrs["cloudflare.do.storage.key"] = first;
1890
- }
1891
- else if (Array.isArray(first)) {
2100
+ if (Array.isArray(first)) {
1892
2101
  attrs["cloudflare.do.storage.key_count"] = first.length;
1893
2102
  }
1894
2103
  else if (op === "list" &&
@@ -1898,8 +2107,17 @@ function wrapDurableObjectStorage(storage, startSpan) {
1898
2107
  attrs["cloudflare.do.storage.limit"] = first.limit;
1899
2108
  }
1900
2109
  const span = startSpan(`DO storage ${op}`, attrs, SpanKind.INTERNAL);
2110
+ // Keys are user data: only the hash is ever emitted (omitted if hashing fails).
2111
+ return runWithSpanKeyed(span, "cloudflare.do.storage.key", keysOf(first), () => Promise.resolve(underlying.apply(storage, args)));
2112
+ };
2113
+ const wrapAlarm = (op) => async (...args) => {
2114
+ const underlying = storage[op];
2115
+ if (isTracingSuppressed(context.active()))
2116
+ return underlying.apply(storage, args);
2117
+ const span = startSpan(`durable_object_alarms_${op}`, doId ? { "cloudflare.durable_object.id": doId } : {}, SpanKind.INTERNAL);
1901
2118
  return runWithSpan(span, () => Promise.resolve(underlying.apply(storage, args)));
1902
2119
  };
2120
+ let tracedSql;
1903
2121
  // A dedicated proxy (not makeProxy) because `storage` is a VALUE property on
1904
2122
  // the state object, not a method — handlers there must be getters.
1905
2123
  return new Proxy(storage, {
@@ -1908,10 +2126,31 @@ function wrapDurableObjectStorage(storage, startSpan) {
1908
2126
  if ((prop === "get" || prop === "put" || prop === "delete" || prop === "list") && typeof val === "function") {
1909
2127
  return wrapOp(prop);
1910
2128
  }
2129
+ if ((prop === "getAlarm" || prop === "setAlarm" || prop === "deleteAlarm") &&
2130
+ typeof val === "function") {
2131
+ return wrapAlarm(prop);
2132
+ }
2133
+ if (prop === "sql" && val && typeof val === "object") {
2134
+ return (tracedSql ??= wrapDurableObjectSql(val, startSpan, doId, pending));
2135
+ }
1911
2136
  return typeof val === "function" ? val.bind(t) : val;
1912
2137
  },
1913
2138
  });
1914
2139
  }
2140
+ /** The DO id string from a raw DurableObjectState; undefined when unavailable. */
2141
+ function durableObjectIdOf(state) {
2142
+ try {
2143
+ const raw = state?.id?.toString?.();
2144
+ return typeof raw === "string" && raw ? raw : undefined;
2145
+ }
2146
+ catch {
2147
+ return undefined;
2148
+ }
2149
+ }
2150
+ const doIdAttrs = (state) => {
2151
+ const id = durableObjectIdOf(state);
2152
+ return id ? { "cloudflare.durable_object.id": id } : {};
2153
+ };
1915
2154
  /** Proxy a DurableObjectState so `state.storage` resolves to the traced proxy. */
1916
2155
  function wrapDurableObjectState(state, startSpan) {
1917
2156
  if (!state || typeof state !== "object")
@@ -1919,7 +2158,18 @@ function wrapDurableObjectState(state, startSpan) {
1919
2158
  const s = state;
1920
2159
  if (!s.storage || typeof s.storage !== "object")
1921
2160
  return state;
1922
- const tracedStorage = wrapDurableObjectStorage(s.storage, startSpan);
2161
+ let doId;
2162
+ try {
2163
+ const raw = s.id?.toString?.();
2164
+ if (typeof raw === "string" && raw)
2165
+ doId = raw;
2166
+ }
2167
+ catch {
2168
+ // An id without a usable toString() just omits the attribute.
2169
+ }
2170
+ const pending = new Set();
2171
+ pendingSqlSpans.set(s, pending);
2172
+ const tracedStorage = wrapDurableObjectStorage(s.storage, startSpan, doId, pending);
1923
2173
  return new Proxy(s, {
1924
2174
  get(t, prop, _receiver) {
1925
2175
  if (prop === "storage")
@@ -1931,6 +2181,10 @@ function wrapDurableObjectState(state, startSpan) {
1931
2181
  }
1932
2182
  /** Prototype methods that are wrapped specially (never treated as RPC). */
1933
2183
  const DO_LIFECYCLE_METHODS = new Set(["fetch", "alarm", "constructor"]);
2184
+ /** WebSocket hibernation handlers: wrapped as named lifecycle spans, never as RPC. */
2185
+ const DO_WEBSOCKET_METHODS = ["webSocketMessage", "webSocketClose", "webSocketError"];
2186
+ for (const m of DO_WEBSOCKET_METHODS)
2187
+ DO_LIFECYCLE_METHODS.add(m);
1934
2188
  const MAX_DO_RPC_METHODS = 32;
1935
2189
  /**
1936
2190
  * Decorate a Durable Object class:
@@ -2011,9 +2265,11 @@ export function wrapDurableObjectClass(cls, opts = {}) {
2011
2265
  "http.request.method": request.method,
2012
2266
  "url.path": url.pathname,
2013
2267
  "server.address": url.host,
2268
+ "cloudflare.handler_type": "fetch",
2269
+ ...doIdAttrs(this.__heystackState),
2014
2270
  }, SpanKind.SERVER, startCtx);
2015
2271
  try {
2016
- const response = (await context.with(trace.setSpan(startCtx, span), () => origFetch.call(this, request)));
2272
+ const response = (await context.with(trace.setSpan(startCtx, span).setValue(DO_INVOCATION_KEY, span), () => origFetch.call(this, request)));
2017
2273
  span.setAttribute("http.response.status_code", response.status);
2018
2274
  span.setStatus({
2019
2275
  code: response.status >= 500 ? SpanStatusCode.ERROR : SpanStatusCode.UNSET,
@@ -2030,7 +2286,7 @@ export function wrapDurableObjectClass(cls, opts = {}) {
2030
2286
  }
2031
2287
  finally {
2032
2288
  span.end();
2033
- scheduleStateFlush(this.__heystackState);
2289
+ scheduleStateFlush(this.__heystackState, span);
2034
2290
  }
2035
2291
  };
2036
2292
  }
@@ -2039,9 +2295,9 @@ export function wrapDurableObjectClass(cls, opts = {}) {
2039
2295
  if (isTracingSuppressed(context.active())) {
2040
2296
  return origAlarm.apply(this, args);
2041
2297
  }
2042
- const span = startSpan("DO alarm", {}, SpanKind.INTERNAL);
2298
+ const span = startSpan("DO alarm", { "cloudflare.handler_type": "alarm", ...doIdAttrs(this.__heystackState) }, SpanKind.INTERNAL);
2043
2299
  try {
2044
- return await context.with(trace.setSpan(context.active(), span), () => origAlarm.apply(this, args));
2300
+ return await context.with(trace.setSpan(context.active(), span).setValue(DO_INVOCATION_KEY, span), () => origAlarm.apply(this, args));
2045
2301
  }
2046
2302
  catch (error) {
2047
2303
  span.recordException(error instanceof Error ? error : new Error(String(error)));
@@ -2053,22 +2309,20 @@ export function wrapDurableObjectClass(cls, opts = {}) {
2053
2309
  }
2054
2310
  finally {
2055
2311
  span.end();
2056
- scheduleStateFlush(this.__heystackState);
2312
+ scheduleStateFlush(this.__heystackState, span);
2057
2313
  }
2058
2314
  };
2059
2315
  }
2060
- for (const method of rpcMethods) {
2316
+ const className = Base.name || "DurableObject";
2317
+ const wrapMethod = (method, spanName, attrs) => {
2061
2318
  const orig = proto[method];
2062
2319
  Wrapped.prototype[method] = async function (...args) {
2063
2320
  if (isTracingSuppressed(context.active())) {
2064
2321
  return orig.apply(this, args);
2065
2322
  }
2066
- const span = startSpan(`DO rpc ${method}`, {
2067
- "rpc.system": "cloudflare.durable_object",
2068
- "rpc.method": method,
2069
- }, SpanKind.SERVER);
2323
+ const span = startSpan(spanName, { ...attrs, ...doIdAttrs(this.__heystackState) }, SpanKind.SERVER);
2070
2324
  try {
2071
- return await context.with(trace.setSpan(context.active(), span), () => orig.apply(this, args));
2325
+ return await context.with(trace.setSpan(context.active(), span).setValue(DO_INVOCATION_KEY, span), () => orig.apply(this, args));
2072
2326
  }
2073
2327
  catch (error) {
2074
2328
  span.recordException(error instanceof Error ? error : new Error(String(error)));
@@ -2080,9 +2334,21 @@ export function wrapDurableObjectClass(cls, opts = {}) {
2080
2334
  }
2081
2335
  finally {
2082
2336
  span.end();
2083
- scheduleStateFlush(this.__heystackState);
2337
+ scheduleStateFlush(this.__heystackState, span);
2084
2338
  }
2085
2339
  };
2340
+ };
2341
+ for (const method of DO_WEBSOCKET_METHODS) {
2342
+ if (typeof proto[method] === "function") {
2343
+ wrapMethod(method, `DO ${className} ${method}`, { "cloudflare.handler_type": method });
2344
+ }
2345
+ }
2346
+ for (const method of rpcMethods) {
2347
+ wrapMethod(method, `DO rpc ${method}`, {
2348
+ "rpc.system": "cloudflare.durable_object",
2349
+ "rpc.method": method,
2350
+ "cloudflare.handler_type": "rpc",
2351
+ });
2086
2352
  }
2087
2353
  return Wrapped;
2088
2354
  }
@@ -2225,7 +2491,7 @@ export function wrapWorkflowEntrypointClass(cls, opts = {}) {
2225
2491
  return origRun.call(this, event, step, ...rest);
2226
2492
  }
2227
2493
  const attrs = workflowRunAttrs(event);
2228
- const span = startSpan("workflow run", attrs, SpanKind.INTERNAL);
2494
+ const span = startSpan("workflow run", { ...attrs, "cloudflare.handler_type": "workflow" }, SpanKind.INTERNAL);
2229
2495
  try {
2230
2496
  return await context.with(trace.setSpan(context.active(), span), () => origRun.call(this, event, step && typeof step === "object"
2231
2497
  ? wrapWorkflowStep(step, startSpan, this.__heystackState)
@@ -2246,25 +2512,155 @@ export function wrapWorkflowEntrypointClass(cls, opts = {}) {
2246
2512
  };
2247
2513
  return Wrapped;
2248
2514
  }
2515
+ // ---------------------------------------------------------------------------
2516
+ // WorkerEntrypoint CLASS instrumentation
2517
+ // ---------------------------------------------------------------------------
2518
+ /** Handler names and RpcTarget plumbing that are never treated as RPC methods. */
2519
+ const ENTRYPOINT_NON_RPC = new Set([
2520
+ "constructor",
2521
+ "fetch",
2522
+ "tail",
2523
+ "trace",
2524
+ "scheduled",
2525
+ "queue",
2526
+ "email",
2527
+ "test",
2528
+ "then",
2529
+ "toJSON",
2530
+ "connect",
2531
+ "tailStream",
2532
+ ]);
2533
+ const ENTRYPOINT_BASES = new Set(["WorkerEntrypoint", "RpcTarget", "DurableObject"]);
2534
+ const MAX_ENTRYPOINT_RPC_METHODS = 64;
2535
+ /**
2536
+ * Decorate a `WorkerEntrypoint` class (the target of a Service binding):
2537
+ * - `fetch()` is traced exactly like an `instrument()`ed handler (SERVER span,
2538
+ * inbound `traceparent` continued, export drained via `ctx.waitUntil`).
2539
+ * - every public RPC method gets a SERVER span `rpc <method>` with
2540
+ * `jsrpc.method` / `rpc.method`. Return values (including stubs, RpcTargets
2541
+ * and custom thenables), `this` and errors pass through untouched.
2542
+ *
2543
+ * RPC spans exist in DIRECT mode only. When Cloudflare native tracing is active
2544
+ * it already emits `jsrpc` spans, so RPC calls pass straight through and `fetch`
2545
+ * takes `instrument()`'s native path. Constructor, `#private`, `_underscore` and
2546
+ * Symbol-keyed members are never wrapped.
2547
+ *
2548
+ * export default wrapWorkerEntrypointClass(class Api extends WorkerEntrypoint { ... }, { service: "api" });
2549
+ */
2550
+ export function wrapWorkerEntrypointClass(cls, cfg) {
2551
+ const Base = cls;
2552
+ const proto = Base.prototype;
2553
+ const instances = new WeakMap();
2554
+ // Enumerate public RPC methods without invoking any getter.
2555
+ const rpcMethods = [];
2556
+ try {
2557
+ let p = Base.prototype;
2558
+ for (let depth = 0; depth < 6 && p && p !== Object.prototype; depth++) {
2559
+ const ctorName = p.constructor?.name;
2560
+ if (depth > 0 && ctorName && ENTRYPOINT_BASES.has(ctorName))
2561
+ break;
2562
+ for (const key of Object.getOwnPropertyNames(p)) {
2563
+ if (ENTRYPOINT_NON_RPC.has(key) || key.startsWith("_") || key.startsWith("#"))
2564
+ continue;
2565
+ if (rpcMethods.includes(key) || rpcMethods.length >= MAX_ENTRYPOINT_RPC_METHODS)
2566
+ continue;
2567
+ if (typeof Object.getOwnPropertyDescriptor(p, key)?.value === "function")
2568
+ rpcMethods.push(key);
2569
+ }
2570
+ p = Object.getPrototypeOf(p);
2571
+ }
2572
+ }
2573
+ catch {
2574
+ // Best-effort enumeration — a hostile prototype must never break decoration.
2575
+ }
2576
+ class Wrapped extends Base {
2577
+ constructor(...args) {
2578
+ // The entrypoint's own `this.env` bindings get traced too, parented to
2579
+ // whichever RPC/fetch span is active when they are used.
2580
+ const env = args[1];
2581
+ const rawEnv = env;
2582
+ if (cfg.instrumentBindings !== false && env && typeof env === "object") {
2583
+ const native = cfg.nativeTracing === "direct" ? null : getNativeTracing(args[0], cfg.tracing);
2584
+ if (native) {
2585
+ args[1] = nativeInstrumentedEnv(env, native, cfg);
2586
+ }
2587
+ else if (cfg.instrumentBindings) {
2588
+ args[1] = instrumentEnv(env, {
2589
+ startSpan: (name, attrs, kind) => trace.getTracer("heystack").startSpan(name, { attributes: attrs, kind }, context.active()),
2590
+ select: cfg.instrumentBindings,
2591
+ });
2592
+ }
2593
+ }
2594
+ super(...args);
2595
+ instances.set(this, { ctx: args[0], env: args[1], rawEnv });
2596
+ }
2597
+ }
2598
+ try {
2599
+ Object.defineProperty(Wrapped, "name", { value: Base.name, configurable: true });
2600
+ }
2601
+ catch {
2602
+ // Non-configurable name: harmless.
2603
+ }
2604
+ const origFetch = proto.fetch;
2605
+ if (typeof origFetch === "function") {
2606
+ // Built once per instance (WorkerEntrypoint.fetch takes only the request).
2607
+ const handlers = new WeakMap();
2608
+ Wrapped.prototype.fetch = function (request) {
2609
+ const inst = instances.get(this);
2610
+ let handler = handlers.get(this);
2611
+ if (!handler) {
2612
+ handler = instrument({ fetch: (req) => origFetch.call(this, req) }, cfg);
2613
+ handlers.set(this, handler);
2614
+ }
2615
+ return handler.fetch(request, inst?.rawEnv ?? this.env, inst?.ctx ?? this.ctx);
2616
+ };
2617
+ }
2618
+ for (const method of rpcMethods) {
2619
+ const orig = proto[method];
2620
+ Wrapped.prototype[method] = function (...args) {
2621
+ if (isTracingSuppressed(context.active()))
2622
+ return orig.apply(this, args);
2623
+ const inst = instances.get(this) ?? {
2624
+ ctx: this.ctx,
2625
+ env: this.env,
2626
+ };
2627
+ // Native tracing owns jsrpc spans: pass straight through.
2628
+ if (cfg.nativeTracing !== "direct" && getNativeTracing(inst.ctx, cfg.tracing)) {
2629
+ return orig.apply(this, args);
2630
+ }
2631
+ const apiKey = cfg.apiKey ?? inst.env?.HEYSTACK_API_KEY;
2632
+ if (!apiKey) {
2633
+ warnOnceNoKey();
2634
+ return orig.apply(this, args);
2635
+ }
2636
+ const provider = ensureGlobalProvider({ ...cfg, apiKey });
2637
+ requestDrainWired = true;
2638
+ const span = trace.getTracer("heystack").startSpan(`rpc ${method}`, { kind: SpanKind.SERVER, attributes: { "jsrpc.method": method, "rpc.method": method, "cloudflare.handler_type": "rpc" } }, context.active());
2639
+ // Only the three members the shared tracer helper uses; `end` also drains.
2640
+ const tracked = {
2641
+ recordException: (e) => span.recordException(e),
2642
+ setStatus: (st) => span.setStatus(st),
2643
+ end: () => {
2644
+ span.end();
2645
+ try {
2646
+ inst.ctx?.waitUntil?.(drainWithTimeout(provider));
2647
+ }
2648
+ catch {
2649
+ // A rejected host waitUntil must not break the RPC call.
2650
+ }
2651
+ },
2652
+ };
2653
+ return runWithSpanPreservingReturn(tracked, () => context.with(trace.setSpan(context.active(), span), () => orig.apply(this, args)));
2654
+ };
2655
+ }
2656
+ return Wrapped;
2657
+ }
2249
2658
  export async function withSpan(name, attrsOrFn, maybeFn) {
2250
2659
  const attrs = typeof attrsOrFn === "function" ? undefined : attrsOrFn;
2251
2660
  const fn = (typeof attrsOrFn === "function" ? attrsOrFn : maybeFn);
2252
2661
  const nativeTracing = nativeTracingFromContext();
2253
2662
  if (nativeTracing) {
2254
- return nativeTracing.enterSpan(name, async (nativeSpan) => {
2255
- const adapter = new NativeSpanAdapter(nativeSpan);
2256
- if (attrs)
2257
- adapter.setAttributes(attrs);
2258
- const nativeContext = trace.setSpan(context.active().setValue(NATIVE_TRACING_CONTEXT_KEY, nativeTracing), adapter);
2259
- try {
2260
- return await context.with(nativeContext, () => fn(adapter));
2261
- }
2262
- catch (err) {
2263
- adapter.recordException(err instanceof Error ? err : new Error(String(err)));
2264
- adapter.setStatus({ code: SpanStatusCode.ERROR, message: err instanceof Error ? err.message : String(err) });
2265
- throw err;
2266
- }
2267
- });
2663
+ return runNativeSpan(nativeTracing, name, attrs ?? {}, fn);
2268
2664
  }
2269
2665
  const tracer = trace.getTracer("heystack");
2270
2666
  const span = tracer.startSpan(name, attrs ? { attributes: attrs } : undefined);
@@ -2313,3 +2709,5 @@ export { llmRequestAttrs as genAiRequestAttrs } from "./llm-enrich.js";
2313
2709
  * Workers AI results (`usage.prompt_tokens`/`completion_tokens`) included.
2314
2710
  */
2315
2711
  export { llmResponseAttrs as genAiResponseAttrs } from "./llm-enrich.js";
2712
+ export { NATIVELY_COVERED, NATIVE_PROPAGATES_EXTERNAL, shouldEnrichNatively, wrapSqlClient };
2713
+ export { matchRoute } from "./workers-routes.js";