@heystack/otel 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,9 +7,11 @@
7
7
  // WinterCG-safe: no Node-builtin imports. Span factory is injected so the logic
8
8
  // is pure and unit-testable without a global provider.
9
9
  // ---------------------------------------------------------------------------
10
- import { context, SpanKind, SpanStatusCode } from "@opentelemetry/api";
10
+ import { context, INVALID_SPAN_CONTEXT, isSpanContextValid, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
11
11
  import { isTracingSuppressed } from "@opentelemetry/core";
12
+ import { scrubKeysAsync } from "./hash.js";
12
13
  import { makeTraceparent } from "./traceparent.js";
14
+ import { nativeTracingFromContext, runNativeSpan } from "./workers-native.js";
13
15
  // ---------------------------------------------------------------------------
14
16
  // Duck-type detectors — conservative; require the distinctive method set
15
17
  // exactly as documented in the task brief.
@@ -61,6 +63,78 @@ function isAnalyticsEngineLike(b) {
61
63
  function isServiceLike(b) {
62
64
  return typeof b?.fetch === "function";
63
65
  }
66
+ /** A service binding to a WorkerEntrypoint answers EVERY property name with a callable
67
+ * JS RPC stub, so it would pass every duck-typed check above (`prepare` looks like D1).
68
+ * No real binding has a function under this name: one that does is an RPC stub. */
69
+ const RPC_PROBE = "__heystackRpcProbe__";
70
+ function isRpcServiceLike(b) {
71
+ try {
72
+ return typeof b?.fetch === "function" && typeof b?.[RPC_PROBE] === "function";
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ /**
79
+ * Adapt a callback-scoped `withSpan` to the immediate-return `startSpan` shape
80
+ * the legacy wrappers use. The callback stays open (pending promise) until the
81
+ * wrapper calls `end()`; calls made before the callback has run are buffered.
82
+ */
83
+ function startSpanFromWithSpan(withSpan) {
84
+ return (name, attrs, kind) => {
85
+ let real;
86
+ const queue = [];
87
+ let release = () => undefined;
88
+ const done = new Promise((resolve) => { release = resolve; });
89
+ let ended = false;
90
+ const apply = (f) => {
91
+ if (real)
92
+ f(real);
93
+ else
94
+ queue.push(f);
95
+ };
96
+ void withSpan(name, attrs, kind, (s) => {
97
+ real = s;
98
+ for (const f of queue.splice(0))
99
+ f(s);
100
+ return done;
101
+ }).catch(() => undefined);
102
+ const deferred = {
103
+ spanContext: () => real?.spanContext() ?? INVALID_SPAN_CONTEXT,
104
+ setAttribute(k, v) { apply((s) => s.setAttribute(k, v)); return deferred; },
105
+ setAttributes(a) { apply((s) => s.setAttributes(a)); return deferred; },
106
+ addEvent(n, a) { apply((s) => s.addEvent(n, a)); return deferred; },
107
+ addLink() { return deferred; },
108
+ addLinks() { return deferred; },
109
+ setStatus(st) { apply((s) => s.setStatus(st)); return deferred; },
110
+ updateName() { return deferred; },
111
+ end() {
112
+ if (ended)
113
+ return;
114
+ ended = true;
115
+ apply((s) => s.end());
116
+ release();
117
+ },
118
+ isRecording: () => real?.isRecording() ?? true,
119
+ recordException(e) { apply((s) => s.recordException(e)); },
120
+ };
121
+ return deferred;
122
+ };
123
+ }
124
+ /**
125
+ * Run an async operation inside a span: `withSpan` when native, else
126
+ * `startSpan` + {@link runWithSpan}.
127
+ */
128
+ function traced(opts, name, attrs, kind, fn) {
129
+ if (opts.withSpan)
130
+ return opts.withSpan(name, attrs, kind, fn);
131
+ const span = opts.startSpan(name, attrs, kind);
132
+ return runWithSpan(span, () => fn(span));
133
+ }
134
+ /** Native name when `opts.nativeNames`, else the direct-mode name. */
135
+ function nm(opts, direct, native) {
136
+ return opts.nativeNames ? native : direct;
137
+ }
64
138
  // ---------------------------------------------------------------------------
65
139
  // Span lifecycle helper
66
140
  // ---------------------------------------------------------------------------
@@ -85,6 +159,68 @@ export async function runWithSpan(span, fn) {
85
159
  span.end();
86
160
  }
87
161
  }
162
+ /** Plain string keys from a string / string[] argument (anything else yields none). */
163
+ export function keysOf(value) {
164
+ if (typeof value === "string")
165
+ return [value];
166
+ if (Array.isArray(value))
167
+ return value.filter((v) => typeof v === "string");
168
+ return [];
169
+ }
170
+ /** At most this many keys of a multi-key call are hashed and recorded. */
171
+ const MAX_HASHED_KEYS = 10;
172
+ /** Key hashes still in flight; exports drain these first so no span is flushed half-built. */
173
+ const pendingKeyHashes = new Set();
174
+ /** Resolves once every in-flight key hash has attached its attribute and ended its span. */
175
+ export async function settlePendingKeyHashes() {
176
+ while (pendingKeyHashes.size > 0)
177
+ await Promise.allSettled([...pendingKeyHashes]);
178
+ }
179
+ /**
180
+ * Like {@link runWithSpan} for operations that carry user-controlled keys. The
181
+ * hash is computed concurrently with the call and attached under `attr` before
182
+ * the span ends; it can neither delay the call's result nor change its error.
183
+ * If hashing is impossible the attribute is omitted (never the raw key).
184
+ */
185
+ export async function runWithSpanKeyed(span, attr, keys, fn, countAttr) {
186
+ // Bounded work per call: hash only the first MAX_HASHED_KEYS; the count is exact.
187
+ if (countAttr && keys.length > 0)
188
+ span.setAttribute(countAttr, keys.length);
189
+ const hashed = keys.length > 0 ? scrubKeysAsync(keys.slice(0, MAX_HASHED_KEYS)) : undefined;
190
+ try {
191
+ return await fn();
192
+ }
193
+ catch (err) {
194
+ recordSpanError(span, err);
195
+ throw err;
196
+ }
197
+ finally {
198
+ const end = () => {
199
+ try {
200
+ span.end();
201
+ }
202
+ catch {
203
+ // Telemetry must never surface errors.
204
+ }
205
+ };
206
+ if (hashed) {
207
+ const done = hashed.then((h) => {
208
+ try {
209
+ if (h !== undefined)
210
+ span.setAttribute(attr, h);
211
+ }
212
+ catch {
213
+ // ignore
214
+ }
215
+ end();
216
+ }, end).finally(() => pendingKeyHashes.delete(done));
217
+ pendingKeyHashes.add(done);
218
+ }
219
+ else {
220
+ end();
221
+ }
222
+ }
223
+ }
88
224
  function runSyncWithSpan(span, fn) {
89
225
  try {
90
226
  return fn();
@@ -113,7 +249,7 @@ function recordSpanError(span, error) {
113
249
  });
114
250
  }
115
251
  /** Trace a call without replacing synchronous, Promise, or custom thenable results. */
116
- function runWithSpanPreservingReturn(span, fn) {
252
+ export function runWithSpanPreservingReturn(span, fn) {
117
253
  try {
118
254
  const result = fn();
119
255
  if (isPromiseLike(result)) {
@@ -200,6 +336,8 @@ function wrapD1Statement(stmt, sql, opts) {
200
336
  const span = opts.startSpan(`D1 ${op}`, {
201
337
  "db.system": "d1",
202
338
  "db.statement": sql,
339
+ "db.system.name": "cloudflare-d1",
340
+ "db.query.text": sql,
203
341
  });
204
342
  return runWithSpan(span, () => stmt[op](...args));
205
343
  };
@@ -225,6 +363,7 @@ function wrapD1(binding, opts) {
225
363
  }
226
364
  const span = opts.startSpan("D1 batch", {
227
365
  "db.system": "d1",
366
+ "db.system.name": "cloudflare-d1",
228
367
  "db.operation.name": "batch",
229
368
  "db.operation.batch.size": statements.length,
230
369
  });
@@ -235,6 +374,7 @@ function wrapD1(binding, opts) {
235
374
  return binding.exec(query);
236
375
  const span = opts.startSpan("D1 exec", {
237
376
  "db.system": "d1",
377
+ "db.system.name": "cloudflare-d1",
238
378
  "db.operation.name": "exec",
239
379
  });
240
380
  return runWithSpan(span, () => binding.exec(query));
@@ -255,6 +395,7 @@ function wrapD1Session(session, opts) {
255
395
  }
256
396
  const span = opts.startSpan("D1 session batch", {
257
397
  "db.system": "d1",
398
+ "db.system.name": "cloudflare-d1",
258
399
  "db.operation.name": "batch",
259
400
  "db.operation.batch.size": statements.length,
260
401
  });
@@ -266,39 +407,33 @@ function wrapD1Session(session, opts) {
266
407
  // KV wrappers
267
408
  // ---------------------------------------------------------------------------
268
409
  function wrapKV(binding, opts, namespace) {
269
- const wrapOp = (op, getKey) => async (...args) => {
410
+ const wrapOp = (op, keyed = false) => async (...args) => {
270
411
  if (isTracingSuppressed(context.active())) {
271
412
  return binding[op](...args);
272
413
  }
273
- const attrs = { "kv.namespace": namespace };
274
- const key = getKey?.(...args);
275
- if (key !== undefined)
276
- attrs["kv.key"] = key;
277
- const span = opts.startSpan(`KV ${op}`, attrs);
278
- return runWithSpan(span, () => binding[op](...args));
414
+ const span = opts.startSpan(`KV ${op}`, { "kv.namespace": namespace });
415
+ return runWithSpanKeyed(span, "cloudflare.kv.query.keys", keyed ? keysOf(args[0]) : [], () => binding[op](...args), "cloudflare.kv.query.key_count");
279
416
  };
280
417
  return makeProxy(binding, {
281
- get: wrapOp("get", (key) => key),
282
- getWithMetadata: wrapOp("getWithMetadata", (key) => key),
283
- put: wrapOp("put", (key) => key),
418
+ get: wrapOp("get", true),
419
+ getWithMetadata: wrapOp("getWithMetadata", true),
420
+ put: wrapOp("put", true),
284
421
  list: wrapOp("list"),
285
- delete: wrapOp("delete", (key) => key),
422
+ delete: wrapOp("delete", true),
286
423
  });
287
424
  }
288
425
  // ---------------------------------------------------------------------------
289
426
  // R2 wrappers
290
427
  // ---------------------------------------------------------------------------
428
+ /** R2 object keys are user data: only the hashed form is ever emitted. */
429
+ const R2_KEY_ATTR = "cloudflare.r2.request.key";
291
430
  function wrapR2(binding, opts, bucket) {
292
- const wrapOp = (op, getKey) => async (...args) => {
431
+ const wrapOp = (op, keyed = false) => async (...args) => {
293
432
  if (isTracingSuppressed(context.active())) {
294
433
  return binding[op](...args);
295
434
  }
296
- const attrs = { "r2.bucket": bucket };
297
- const key = getKey?.(...args);
298
- if (key !== undefined)
299
- attrs["r2.key"] = key;
300
- const span = opts.startSpan(`R2 ${op}`, attrs);
301
- return runWithSpan(span, () => binding[op](...args));
435
+ const span = opts.startSpan(`R2 ${op}`, { "r2.bucket": bucket });
436
+ return runWithSpanKeyed(span, R2_KEY_ATTR, keyed ? keysOf(args[0]) : [], () => binding[op](...args), "cloudflare.r2.request.key_count");
302
437
  };
303
438
  const wrapMultipart = (upload, key) => makeProxy(upload, {
304
439
  uploadPart: async (partNumber, ...args) => {
@@ -307,10 +442,9 @@ function wrapR2(binding, opts, bucket) {
307
442
  }
308
443
  const span = opts.startSpan("R2 uploadPart", {
309
444
  "r2.bucket": bucket,
310
- "r2.key": key,
311
445
  "r2.multipart.part_number": partNumber,
312
446
  });
313
- return runWithSpan(span, () => upload.uploadPart(partNumber, ...args));
447
+ return runWithSpanKeyed(span, R2_KEY_ATTR, [key], () => upload.uploadPart(partNumber, ...args));
314
448
  },
315
449
  abort: wrapMultipartOp("abort", upload, key),
316
450
  complete: wrapMultipartOp("complete", upload, key, (parts) => ({
@@ -323,28 +457,24 @@ function wrapR2(binding, opts, bucket) {
323
457
  return upload[op](...args);
324
458
  const span = opts.startSpan(`R2 ${op}`, {
325
459
  "r2.bucket": bucket,
326
- "r2.key": key,
327
460
  ...extraAttrs?.(...args),
328
461
  });
329
- return runWithSpan(span, () => upload[op](...args));
462
+ return runWithSpanKeyed(span, R2_KEY_ATTR, [key], () => upload[op](...args));
330
463
  };
331
464
  }
332
465
  return makeProxy(binding, {
333
- get: wrapOp("get", (key) => key),
334
- put: wrapOp("put", (key) => key),
335
- head: wrapOp("head", (key) => key),
336
- delete: wrapOp("delete", (key) => key),
466
+ get: wrapOp("get", true),
467
+ put: wrapOp("put", true),
468
+ head: wrapOp("head", true),
469
+ delete: wrapOp("delete", true),
337
470
  list: wrapOp("list"),
338
471
  async createMultipartUpload(key, ...args) {
339
472
  if (isTracingSuppressed(context.active())) {
340
473
  const upload = await binding.createMultipartUpload(key, ...args);
341
474
  return wrapMultipart(upload, key);
342
475
  }
343
- const span = opts.startSpan("R2 createMultipartUpload", {
344
- "r2.bucket": bucket,
345
- "r2.key": key,
346
- });
347
- const upload = await runWithSpan(span, () => binding.createMultipartUpload(key, ...args));
476
+ const span = opts.startSpan("R2 createMultipartUpload", { "r2.bucket": bucket });
477
+ const upload = await runWithSpanKeyed(span, R2_KEY_ATTR, [key], () => binding.createMultipartUpload(key, ...args));
348
478
  return wrapMultipart(upload, key);
349
479
  },
350
480
  resumeMultipartUpload(key, uploadId) {
@@ -360,18 +490,23 @@ function wrapVectorize(binding, opts, indexName) {
360
490
  if (isTracingSuppressed(context.active())) {
361
491
  return binding[op](...args);
362
492
  }
363
- const span = opts.startSpan(`Vectorize ${op}`, {
364
- "vectorize.index": indexName,
365
- });
366
- return runWithSpan(span, () => binding[op](...args));
493
+ const attrs = { "vectorize.index": indexName };
494
+ if (opts.nativeNames) {
495
+ attrs["cloudflare.binding.type"] = "vectorize";
496
+ attrs["cloudflare.binding.name"] = indexName;
497
+ }
498
+ return traced(opts, nm(opts, `Vectorize ${op}`, `vectorize_${op}`), attrs, undefined, () => binding[op](...args));
367
499
  };
368
- return makeProxy(binding, {
500
+ const handlers = {
369
501
  query: wrapOp("query"),
370
502
  upsert: wrapOp("upsert"),
371
503
  insert: wrapOp("insert"),
372
504
  deleteByIds: wrapOp("deleteByIds"),
373
505
  getByIds: wrapOp("getByIds"),
374
- });
506
+ };
507
+ if (opts.nativeNames && typeof binding.describe === "function")
508
+ handlers.describe = wrapOp("describe");
509
+ return makeProxy(binding, handlers);
375
510
  }
376
511
  // ---------------------------------------------------------------------------
377
512
  // Workers AI wrapper
@@ -385,11 +520,17 @@ function wrapWorkersAI(binding, opts, _name) {
385
520
  const attrs = {
386
521
  "gen_ai.system": "cloudflare.workers_ai",
387
522
  };
523
+ if (opts.nativeNames) {
524
+ attrs["cloudflare.binding.type"] = "ai";
525
+ attrs["cloudflare.binding.name"] = _name;
526
+ attrs["gen_ai.provider.name"] = "cloudflare";
527
+ attrs["gen_ai.operation.name"] = "chat";
528
+ }
388
529
  if (typeof args[0] === "string") {
389
530
  attrs["gen_ai.request.model"] = args[0];
390
531
  }
391
- const span = opts.startSpan("AI run", attrs, SpanKind.CLIENT);
392
- try {
532
+ // PRIVACY: never records prompts/completions — only model + token usage.
533
+ return traced(opts, nm(opts, "AI run", "ai_run"), attrs, SpanKind.CLIENT, async (span) => {
393
534
  const result = await binding.run(...args);
394
535
  // Best-effort usage extraction — never read/await/tee the stream.
395
536
  try {
@@ -410,18 +551,7 @@ function wrapWorkersAI(binding, opts, _name) {
410
551
  // Swallow — a weird shape must never throw.
411
552
  }
412
553
  return result;
413
- }
414
- catch (err) {
415
- span.recordException(err instanceof Error ? err : new Error(String(err)));
416
- span.setStatus({
417
- code: SpanStatusCode.ERROR,
418
- message: err instanceof Error ? err.message : String(err),
419
- });
420
- throw err;
421
- }
422
- finally {
423
- span.end();
424
- }
554
+ });
425
555
  },
426
556
  });
427
557
  }
@@ -432,6 +562,7 @@ function wrapQueue(binding, opts, name) {
432
562
  const baseAttrs = {
433
563
  "messaging.system": "cloudflare_queues",
434
564
  "messaging.destination.name": name,
565
+ "cloudflare.queue.name": name,
435
566
  };
436
567
  return makeProxy(binding, {
437
568
  send: async (...args) => {
@@ -481,8 +612,7 @@ function wrapWorkflow(binding, opts, name) {
481
612
  const count = getCount?.(...args);
482
613
  if (count !== undefined)
483
614
  attrs["cloudflare.workflow.batch.size"] = count;
484
- const span = opts.startSpan(`Workflow ${op}`, attrs, kind);
485
- return runWithSpan(span, () => binding[op](...args));
615
+ return traced(opts, nm(opts, `Workflow ${op}`, `workflow_${op}`), attrs, kind, () => binding[op](...args));
486
616
  };
487
617
  return makeProxy(binding, {
488
618
  get: wrapOp("get", SpanKind.CLIENT),
@@ -497,7 +627,7 @@ function wrapRateLimit(binding, opts, name) {
497
627
  if (isTracingSuppressed(context.active()))
498
628
  return binding.limit(...args);
499
629
  const span = opts.startSpan("RateLimit limit", {
500
- "cloudflare.binding.type": "rate_limit",
630
+ "cloudflare.binding.type": "ratelimit",
501
631
  "cloudflare.binding.name": name,
502
632
  });
503
633
  return runWithSpan(span, () => binding.limit(...args));
@@ -509,7 +639,7 @@ function wrapAnalyticsEngine(binding, opts, name) {
509
639
  writeDataPoint(...args) {
510
640
  if (isTracingSuppressed(context.active()))
511
641
  return binding.writeDataPoint(...args);
512
- const span = opts.startSpan("AnalyticsEngine writeDataPoint", {
642
+ const span = opts.startSpan(nm(opts, "AnalyticsEngine writeDataPoint", "analytics_engine_writeDataPoint"), {
513
643
  "cloudflare.binding.type": "analytics_engine",
514
644
  "cloudflare.binding.name": name,
515
645
  });
@@ -550,17 +680,37 @@ export function injectTraceparentArgs(args, traceparent) {
550
680
  return [input, init];
551
681
  }
552
682
  function wrapService(binding, opts, name) {
553
- return makeProxy(binding, {
683
+ const handlers = {
554
684
  fetch: async (...args) => {
555
685
  if (isTracingSuppressed(context.active())) {
556
686
  return binding.fetch(...args);
557
687
  }
558
688
  const span = opts.startSpan(`Service ${name} fetch`, { "peer.service": name }, SpanKind.CLIENT);
559
689
  const sc = span.spanContext();
560
- const traceparent = makeTraceparent(sc);
561
- const injectedArgs = injectTraceparentArgs(args, traceparent);
690
+ // Native spans expose no ids (INVALID context): never inject an all-zero traceparent.
691
+ const injectedArgs = isSpanContextValid(sc)
692
+ ? injectTraceparentArgs(args, makeTraceparent(sc))
693
+ : args;
562
694
  return runWithSpan(span, () => binding.fetch(...injectedArgs));
563
695
  },
696
+ };
697
+ if (!isRpcServiceLike(binding))
698
+ return makeProxy(binding, handlers);
699
+ // JS RPC binding: every other property is a callable stub that must reach the caller
700
+ // untouched. Reading `.bind` on it would itself be an RPC call (to a method named
701
+ // "bind"), and a bound copy would lose nested property chains (`env.SVC.users.get()`).
702
+ return new Proxy(binding, {
703
+ get(target, prop) {
704
+ if (prop === rawTarget)
705
+ return target;
706
+ if (prop === "fetch")
707
+ return handlers.fetch;
708
+ const value = Reflect.get(target, prop, target);
709
+ // connect() is a host method on the Fetcher and needs its receiver.
710
+ if (prop === "connect" && typeof value === "function")
711
+ return Function.prototype.bind.call(value, target);
712
+ return value;
713
+ },
564
714
  });
565
715
  }
566
716
  // ---------------------------------------------------------------------------
@@ -594,7 +744,9 @@ function wrapDurableObjectStub(stub, opts, name) {
594
744
  const span = opts.startSpan(`DO ${name} fetch`, { "peer.service": name }, SpanKind.CLIENT);
595
745
  const sc = span.spanContext();
596
746
  // Propagate into the DO so its own spans join this trace.
597
- const injectedArgs = injectTraceparentArgs(args, makeTraceparent(sc));
747
+ const injectedArgs = isSpanContextValid(sc)
748
+ ? injectTraceparentArgs(args, makeTraceparent(sc))
749
+ : args;
598
750
  return runWithSpanPreservingReturn(span, () => stub.fetch(...injectedArgs));
599
751
  };
600
752
  // workerd exposes RPC methods through a wildcard property hook, so method
@@ -699,7 +851,11 @@ function wrapWorkflowInstance(instance, opts, name) {
699
851
  if (instanceId !== undefined) {
700
852
  attrs["cloudflare.workflow.instance_id"] = instanceId;
701
853
  }
702
- const span = opts.startSpan(`Workflow ${method}`, attrs, SpanKind.CLIENT);
854
+ if (opts.nativeNames) {
855
+ attrs["cloudflare.binding.type"] = "workflow";
856
+ attrs["cloudflare.binding.name"] = name;
857
+ }
858
+ const span = opts.startSpan(nm(opts, `Workflow ${method}`, `workflow_${method}`), attrs, SpanKind.CLIENT);
703
859
  return runWithSpanPreservingReturn(span, () => underlying.apply(instance, args));
704
860
  };
705
861
  }
@@ -746,7 +902,7 @@ function wrapWorkflowBinding(binding, opts, name) {
746
902
  attrs["cloudflare.workflow.instance_ids"] = batchIds;
747
903
  attrs["cloudflare.workflow.batch.size"] = batchIds.length;
748
904
  }
749
- const span = opts.startSpan(`Workflow ${op}`, attrs, SpanKind.CLIENT);
905
+ const span = opts.startSpan(nm(opts, `Workflow ${op}`, `workflow_${op}`), attrs, SpanKind.CLIENT);
750
906
  return runWithSpanPreservingReturn(span, () => mapReturn(binding[op](...args), (value) => wrapWorkflowBindingResult(value, op, opts, name, span)));
751
907
  };
752
908
  }
@@ -785,52 +941,206 @@ function wrapWorkflowBinding(binding, opts, name) {
785
941
  * @param opts - `startSpan` factory (now accepts optional `SpanKind`) + `select` filter.
786
942
  * @returns A shallow copy of `env` with selected bindings replaced by proxies.
787
943
  */
788
- export function instrumentEnv(env, opts) {
944
+ export function instrumentEnv(env, rawOpts) {
945
+ if (typeof rawOpts.startSpan !== "function" && typeof rawOpts.withSpan !== "function") {
946
+ throw new TypeError("instrumentEnv requires opts.startSpan or opts.withSpan");
947
+ }
948
+ // withSpan wins: the legacy wrappers get an adapter so startSpan is never called.
949
+ const opts = rawOpts.withSpan
950
+ ? { ...rawOpts, startSpan: startSpanFromWithSpan(rawOpts.withSpan) }
951
+ : rawOpts;
789
952
  const result = { ...env };
790
- const { select } = opts;
953
+ const { select, kindFilter } = opts;
954
+ const allow = (kind) => !kindFilter || kindFilter(kind);
955
+ // Direct mode: stamp binding type/name on every span a binding emits. Native
956
+ // mode (`withSpan`) already sets them where it enriches, and stays unchanged.
957
+ const stamped = (type, key) => {
958
+ if (rawOpts.withSpan)
959
+ return opts;
960
+ const base = opts.startSpan;
961
+ return {
962
+ ...opts,
963
+ startSpan: (name, attrs, kind) => base(name, { "cloudflare.binding.type": type, "cloudflare.binding.name": key, ...attrs }, kind),
964
+ };
965
+ };
791
966
  for (const key of Object.keys(env)) {
792
967
  // Filter: if select is an array, only wrap keys in the list.
793
968
  if (select !== true && !select.includes(key))
794
969
  continue;
795
970
  const binding = env[key];
796
- if (isD1Like(binding)) {
797
- result[key] = wrapD1(binding, opts);
971
+ if (isRpcServiceLike(binding)) {
972
+ // First: an RPC stub matches every duck-typed check below.
973
+ if (allow("service"))
974
+ result[key] = wrapService(binding, stamped("service", key), key);
975
+ }
976
+ else if (isD1Like(binding)) {
977
+ if (allow("d1"))
978
+ result[key] = wrapD1(binding, stamped("d1", key));
798
979
  }
799
980
  else if (isR2Like(binding)) {
800
981
  // R2 before KV — R2 also has `list`, so checking `head` first avoids mis-classifying.
801
- result[key] = wrapR2(binding, opts, key);
982
+ if (allow("r2"))
983
+ result[key] = wrapR2(binding, stamped("r2", key), key);
802
984
  }
803
985
  else if (isKVLike(binding)) {
804
- result[key] = wrapKV(binding, opts, key);
986
+ if (allow("kv"))
987
+ result[key] = wrapKV(binding, stamped("kv", key), key);
805
988
  }
806
989
  else if (isVectorizeLike(binding)) {
807
- result[key] = wrapVectorize(binding, opts, key);
990
+ if (allow("vectorize"))
991
+ result[key] = wrapVectorize(binding, stamped("vectorize", key), key);
808
992
  }
809
993
  else if (isWorkersAILike(binding)) {
810
- result[key] = wrapWorkersAI(binding, opts, key);
994
+ if (allow("ai"))
995
+ result[key] = wrapWorkersAI(binding, stamped("ai", key), key);
811
996
  }
812
997
  else if (isQueueLike(binding)) {
813
- result[key] = wrapQueue(binding, opts, key);
998
+ if (allow("queue"))
999
+ result[key] = wrapQueue(binding, stamped("queue", key), key);
814
1000
  }
815
1001
  else if (isDurableObjectNamespaceLike(binding)) {
816
- result[key] = wrapDurableObjectNamespace(binding, opts, key);
1002
+ if (allow("durable_object"))
1003
+ result[key] = wrapDurableObjectNamespace(binding, stamped("durable_object", key), key);
817
1004
  }
818
1005
  else if (isWorkflowBindingLike(binding)) {
819
- result[key] = wrapWorkflowBinding(binding, opts, key);
1006
+ if (allow("workflow"))
1007
+ result[key] = wrapWorkflowBinding(binding, stamped("workflow", key), key);
820
1008
  }
821
1009
  else if (isWorkflowLike(binding)) {
822
- result[key] = wrapWorkflow(binding, opts, key);
1010
+ if (allow("workflow"))
1011
+ result[key] = wrapWorkflow(binding, stamped("workflow", key), key);
823
1012
  }
824
1013
  else if (isRateLimitLike(binding)) {
825
- result[key] = wrapRateLimit(binding, opts, key);
1014
+ if (allow("ratelimit"))
1015
+ result[key] = wrapRateLimit(binding, stamped("ratelimit", key), key);
826
1016
  }
827
1017
  else if (isAnalyticsEngineLike(binding)) {
828
- result[key] = wrapAnalyticsEngine(binding, opts, key);
1018
+ if (allow("analytics_engine"))
1019
+ result[key] = wrapAnalyticsEngine(binding, stamped("analytics_engine", key), key);
829
1020
  }
830
1021
  else if (isServiceLike(binding)) {
831
- result[key] = wrapService(binding, opts, key);
1022
+ if (allow("service"))
1023
+ result[key] = wrapService(binding, stamped("service", key), key);
832
1024
  }
833
1025
  // Unrecognised bindings are left as-is.
834
1026
  }
835
1027
  return result;
836
1028
  }
1029
+ // ---------------------------------------------------------------------------
1030
+ // Hyperdrive: wrap the SQL client the app builds on top of the binding
1031
+ // ---------------------------------------------------------------------------
1032
+ /**
1033
+ * Wrap a Postgres/MySQL client (postgres.js, pg, mysql2, ...) created from a
1034
+ * Hyperdrive binding so each `query` / `unsafe` / `execute` call, and each
1035
+ * tagged-template call when the client itself is callable (postgres.js
1036
+ * `sql\`select 1\``), emits a `hyperdrive_query` span. `db.query.text` is
1037
+ * recorded raw (template strings are joined with `?`; ingest sanitises). The
1038
+ * ORIGINAL return value is passed through untouched; the span ends when it
1039
+ * settles. Uses the request's native tracer when present, else the global one.
1040
+ */
1041
+ export function wrapSqlClient(client, opts = {}) {
1042
+ const traceCall = (args, run) => {
1043
+ if (isTracingSuppressed(context.active()))
1044
+ return run();
1045
+ const attrs = { "cloudflare.binding.type": "hyperdrive" };
1046
+ if (opts.name)
1047
+ attrs["cloudflare.binding.name"] = opts.name;
1048
+ if (opts.dbSystem)
1049
+ attrs["db.system.name"] = opts.dbSystem;
1050
+ const first = args[0];
1051
+ if (Array.isArray(first) && "raw" in first)
1052
+ attrs["db.query.text"] = first.join("?");
1053
+ else if (typeof first === "string")
1054
+ attrs["db.query.text"] = first;
1055
+ else if (typeof first?.text === "string") {
1056
+ attrs["db.query.text"] = first.text;
1057
+ }
1058
+ const native = nativeTracingFromContext();
1059
+ const startSpan = () => native
1060
+ ? startSpanFromWithSpan((n, at, _k, fn) => runNativeSpan(native, n, at, fn))("hyperdrive_query", attrs, SpanKind.CLIENT)
1061
+ : trace.getTracer("heystack").startSpan("hyperdrive_query", {
1062
+ attributes: attrs,
1063
+ kind: SpanKind.CLIENT,
1064
+ });
1065
+ let result;
1066
+ let span;
1067
+ try {
1068
+ result = run();
1069
+ }
1070
+ catch (error) {
1071
+ span = startSpan();
1072
+ recordSpanError(span, error);
1073
+ span.end();
1074
+ throw error;
1075
+ }
1076
+ if (!isPromiseLike(result)) {
1077
+ // Synchronous result: nothing lazy to protect.
1078
+ span = startSpan();
1079
+ span.end();
1080
+ return result;
1081
+ }
1082
+ // A plain native Promise is already running and `await` bypasses an own
1083
+ // `then`, so observe it eagerly (safe: observing cannot start anything).
1084
+ if (Object.getPrototypeOf(result) === Promise.prototype) {
1085
+ return runWithSpanPreservingReturn(startSpan(), () => result);
1086
+ }
1087
+ // LAZY thenables (postgres.js `Query`) start executing inside `then()`, so we
1088
+ // must never call `then` ourselves: a fragment nested in another query and
1089
+ // never awaited would otherwise run standalone. Instead shadow `then` on the
1090
+ // instance and start the span on the first consumer-driven call.
1091
+ const origThen = result.then;
1092
+ let started = false;
1093
+ try {
1094
+ Object.defineProperty(result, "then", {
1095
+ configurable: true,
1096
+ writable: true,
1097
+ value: function (onOk, onErr) {
1098
+ if (started)
1099
+ return origThen.call(this, onOk, onErr);
1100
+ started = true;
1101
+ const sp = startSpan();
1102
+ return origThen.call(this, (v) => {
1103
+ try {
1104
+ sp.end();
1105
+ }
1106
+ catch { /* never alter the result */ }
1107
+ return onOk ? onOk(v) : v;
1108
+ }, (e) => {
1109
+ try {
1110
+ recordSpanError(sp, e);
1111
+ sp.end();
1112
+ }
1113
+ catch { /* never alter the result */ }
1114
+ if (onErr)
1115
+ return onErr(e);
1116
+ throw e;
1117
+ });
1118
+ },
1119
+ });
1120
+ }
1121
+ catch {
1122
+ // Frozen / non-extensible: return untraced.
1123
+ }
1124
+ return result;
1125
+ };
1126
+ const methods = new Map();
1127
+ return new Proxy(client, {
1128
+ get(target, prop, receiver) {
1129
+ const value = Reflect.get(target, prop, receiver);
1130
+ if (typeof value !== "function")
1131
+ return value;
1132
+ if (prop === "query" || prop === "unsafe" || prop === "execute") {
1133
+ let wrapped = methods.get(prop);
1134
+ if (!wrapped) {
1135
+ wrapped = (...args) => traceCall(args, () => value.apply(target, args));
1136
+ methods.set(prop, wrapped);
1137
+ }
1138
+ return wrapped;
1139
+ }
1140
+ return value.bind(target);
1141
+ },
1142
+ apply(target, thisArg, args) {
1143
+ return traceCall(args, () => Reflect.apply(target, thisArg, args));
1144
+ },
1145
+ });
1146
+ }