@heystack/otel 0.13.0 → 0.15.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.
@@ -8,12 +8,112 @@ export function detectLLMProvider(host) {
8
8
  return "openai";
9
9
  if (h === "api.anthropic.com")
10
10
  return "anthropic";
11
+ if (h === "api.mistral.ai")
12
+ return "mistral";
11
13
  if (h === "generativelanguage.googleapis.com")
12
14
  return "google";
13
15
  if (h === "gateway.ai.cloudflare.com" || h.endsWith(".gateway.ai.cloudflare.com"))
14
16
  return "cloudflare";
15
17
  return null;
16
18
  }
19
+ /** Map a gateway path token to a provider. `gemini` → google, `workers-ai` → cloudflare. */
20
+ export function providerFromToken(token) {
21
+ switch (token.toLowerCase()) {
22
+ case "openai":
23
+ return "openai";
24
+ case "anthropic":
25
+ return "anthropic";
26
+ case "google":
27
+ case "gemini":
28
+ return "google";
29
+ case "mistral":
30
+ return "mistral";
31
+ case "cloudflare":
32
+ case "workers-ai":
33
+ return "cloudflare";
34
+ default:
35
+ return null;
36
+ }
37
+ }
38
+ function hostMatchesSuffix(host, suffix) {
39
+ const s = suffix.toLowerCase().replace(/^\.+/, "");
40
+ if (!s)
41
+ return false;
42
+ return host === s || host.endsWith("." + s);
43
+ }
44
+ /** Split a path into non-empty segments. */
45
+ function segments(path) {
46
+ return path.split("/").filter((s) => s.length > 0);
47
+ }
48
+ /**
49
+ * Resolve the LLM provider for an outbound URL: direct host table first, then
50
+ * each configured gateway pattern. Returns null when neither matches. Pure and
51
+ * exception-free (a malformed gateway entry is skipped, never thrown).
52
+ */
53
+ export function resolveLLMProvider(url, gateways) {
54
+ const host = url.hostname.toLowerCase();
55
+ const direct = detectLLMProvider(host);
56
+ if (direct)
57
+ return { provider: direct };
58
+ if (!gateways || gateways.length === 0)
59
+ return null;
60
+ const pathSegs = segments(url.pathname);
61
+ for (const gw of gateways) {
62
+ try {
63
+ if (!gw || typeof gw.hostSuffix !== "string" || typeof gw.pathPattern !== "string")
64
+ continue;
65
+ if (!hostMatchesSuffix(host, gw.hostSuffix))
66
+ continue;
67
+ const patternSegs = segments(gw.pathPattern);
68
+ const providerIdx = patternSegs.findIndex((s) => s.toLowerCase() === ":provider");
69
+ if (providerIdx === -1 || pathSegs.length < patternSegs.length)
70
+ continue;
71
+ let ok = true;
72
+ for (let i = 0; i < patternSegs.length; i++) {
73
+ const p = patternSegs[i];
74
+ if (p.startsWith(":"))
75
+ continue; // any single segment
76
+ if (p !== decodeURIComponentSafe(pathSegs[i])) {
77
+ ok = false;
78
+ break;
79
+ }
80
+ }
81
+ if (!ok)
82
+ continue;
83
+ const provider = providerFromToken(decodeURIComponentSafe(pathSegs[providerIdx]));
84
+ if (provider)
85
+ return { provider, gateway: host };
86
+ }
87
+ catch {
88
+ // malformed entry — skip
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+ function decodeURIComponentSafe(s) {
94
+ try {
95
+ return decodeURIComponent(s);
96
+ }
97
+ catch {
98
+ return s;
99
+ }
100
+ }
101
+ /**
102
+ * Best-effort `gen_ai.operation.name` from the request path. Chat-style
103
+ * endpoints (OpenAI/Mistral `chat/completions`, Anthropic `messages`, Google
104
+ * `*generateContent`) → `chat`; `embeddings` → `embeddings`; bare/fim
105
+ * `completions` → `text_completion`; anything else defaults to `chat`.
106
+ */
107
+ export function llmOperationName(pathname) {
108
+ const p = pathname.toLowerCase();
109
+ if (p.includes("/embeddings"))
110
+ return "embeddings";
111
+ if (p.includes("/chat/completions") || p.includes("/messages") || p.includes("generatecontent"))
112
+ return "chat";
113
+ if (p.endsWith("/completions") || p.includes("/completions"))
114
+ return "text_completion";
115
+ return "chat";
116
+ }
17
117
  /** Best-effort gen_ai.request.* from a parsed JSON request body. Never throws. */
18
118
  export function llmRequestAttrs(provider, body) {
19
119
  const out = { "gen_ai.system": provider };
@@ -37,30 +137,29 @@ export function llmResponseAttrs(provider, body) {
37
137
  if (!body || typeof body !== "object" || Array.isArray(body))
38
138
  return out;
39
139
  const b = body;
140
+ // Response model / id. OpenAI, Mistral (OpenAI-compatible) and Anthropic use
141
+ // `model` / `id`; Google uses `modelVersion` / `responseId`.
40
142
  if (typeof b["model"] === "string")
41
143
  out["gen_ai.response.model"] = b["model"];
144
+ else if (typeof b["modelVersion"] === "string")
145
+ out["gen_ai.response.model"] = b["modelVersion"];
42
146
  if (typeof b["id"] === "string")
43
147
  out["gen_ai.response.id"] = b["id"];
44
- // Usage tokens — OpenAI: usage.prompt_tokens / completion_tokens
45
- // Anthropic: usage.input_tokens / output_tokens
46
- if (b["usage"] && typeof b["usage"] === "object" && !Array.isArray(b["usage"])) {
47
- const usage = b["usage"];
48
- // OpenAI shape
49
- if (typeof usage["prompt_tokens"] === "number") {
50
- out["gen_ai.usage.input_tokens"] = usage["prompt_tokens"];
51
- }
52
- if (typeof usage["completion_tokens"] === "number") {
53
- out["gen_ai.usage.output_tokens"] = usage["completion_tokens"];
54
- }
55
- // Anthropic shape (overrides OpenAI if both somehow present)
56
- if (typeof usage["input_tokens"] === "number") {
57
- out["gen_ai.usage.input_tokens"] = usage["input_tokens"];
58
- }
59
- if (typeof usage["output_tokens"] === "number") {
60
- out["gen_ai.usage.output_tokens"] = usage["output_tokens"];
61
- }
148
+ else if (typeof b["responseId"] === "string")
149
+ out["gen_ai.response.id"] = b["responseId"];
150
+ // Usage tokens — OpenAI/Mistral: usage.prompt_tokens / completion_tokens
151
+ // Anthropic: usage.input_tokens / output_tokens
152
+ // Google: usageMetadata.promptTokenCount / candidatesTokenCount
153
+ Object.assign(out, usageAttrs(b["usage"]));
154
+ if (b["usageMetadata"] && typeof b["usageMetadata"] === "object" && !Array.isArray(b["usageMetadata"])) {
155
+ const um = b["usageMetadata"];
156
+ if (typeof um["promptTokenCount"] === "number")
157
+ out["gen_ai.usage.input_tokens"] = um["promptTokenCount"];
158
+ if (typeof um["candidatesTokenCount"] === "number")
159
+ out["gen_ai.usage.output_tokens"] = um["candidatesTokenCount"];
62
160
  }
63
- // Finish reason — OpenAI: choices[0].finish_reason; Anthropic: stop_reason
161
+ // Finish reason — OpenAI/Mistral: choices[0].finish_reason; Anthropic:
162
+ // stop_reason; Google: candidates[0].finishReason
64
163
  if (typeof b["stop_reason"] === "string") {
65
164
  out["gen_ai.response.finish_reason"] = b["stop_reason"];
66
165
  }
@@ -70,9 +169,183 @@ export function llmResponseAttrs(provider, body) {
70
169
  out["gen_ai.response.finish_reason"] = choice["finish_reason"];
71
170
  }
72
171
  }
172
+ else if (Array.isArray(b["candidates"]) && b["candidates"].length > 0) {
173
+ const cand = b["candidates"][0];
174
+ if (cand && typeof cand === "object" && typeof cand["finishReason"] === "string") {
175
+ out["gen_ai.response.finish_reason"] = cand["finishReason"];
176
+ }
177
+ }
73
178
  void provider; // provider available for future provider-specific logic
74
179
  return out;
75
180
  }
181
+ /** `gen_ai.usage.*` from an OpenAI-style or Anthropic-style `usage` object. */
182
+ function usageAttrs(usage) {
183
+ const out = {};
184
+ if (!usage || typeof usage !== "object" || Array.isArray(usage))
185
+ return out;
186
+ const u = usage;
187
+ // OpenAI / Mistral shape
188
+ if (typeof u["prompt_tokens"] === "number")
189
+ out["gen_ai.usage.input_tokens"] = u["prompt_tokens"];
190
+ if (typeof u["completion_tokens"] === "number")
191
+ out["gen_ai.usage.output_tokens"] = u["completion_tokens"];
192
+ // Anthropic shape (overrides OpenAI if both somehow present)
193
+ if (typeof u["input_tokens"] === "number")
194
+ out["gen_ai.usage.input_tokens"] = u["input_tokens"];
195
+ if (typeof u["output_tokens"] === "number")
196
+ out["gen_ai.usage.output_tokens"] = u["output_tokens"];
197
+ return out;
198
+ }
199
+ // ---------------------------------------------------------------------------
200
+ // Streaming (SSE) usage scanning — metadata only, never content
201
+ //
202
+ // Every provider streams `text/event-stream`; token usage arrives in one or two
203
+ // chunks: OpenAI (with `stream_options.include_usage`) and Mistral put a `usage`
204
+ // object in the final data chunk; Anthropic sends `message_start` (model, id,
205
+ // input_tokens) and `message_delta` (output_tokens, stop_reason); Google sends
206
+ // `usageMetadata` on the final chunk. The scanner parses each `data:` line's
207
+ // JSON just long enough to lift those fields and discards the chunk. Content
208
+ // deltas are never retained or copied into attributes.
209
+ // ---------------------------------------------------------------------------
210
+ /** Lines longer than this are skipped unparsed (a bound on per-chunk JSON.parse). */
211
+ export const SSE_MAX_LINE_BYTES = 64 * 1024;
212
+ /**
213
+ * Metadata attrs from ONE parsed stream chunk. Shares the response mapper and
214
+ * adds the Anthropic envelope (`message.{id,model,usage}` on `message_start`,
215
+ * `delta.stop_reason` on `message_delta`). Never throws.
216
+ */
217
+ export function llmStreamChunkAttrs(provider, chunk) {
218
+ const out = llmResponseAttrs(provider, chunk);
219
+ if (!chunk || typeof chunk !== "object" || Array.isArray(chunk))
220
+ return out;
221
+ const c = chunk;
222
+ const msg = c["message"];
223
+ if (msg && typeof msg === "object" && !Array.isArray(msg)) {
224
+ const m = msg;
225
+ if (typeof m["id"] === "string")
226
+ out["gen_ai.response.id"] = m["id"];
227
+ if (typeof m["model"] === "string")
228
+ out["gen_ai.response.model"] = m["model"];
229
+ Object.assign(out, usageAttrs(m["usage"]));
230
+ }
231
+ const delta = c["delta"];
232
+ if (delta && typeof delta === "object" && !Array.isArray(delta)) {
233
+ const d = delta;
234
+ if (typeof d["stop_reason"] === "string")
235
+ out["gen_ai.response.finish_reason"] = d["stop_reason"];
236
+ }
237
+ return out;
238
+ }
239
+ /**
240
+ * Incremental SSE scanner: buffers at most one partial line, parses complete
241
+ * `data:` lines for usage/model/id/finish_reason and drops everything else
242
+ * immediately. Lines over `SSE_MAX_LINE_BYTES` are skipped without parsing.
243
+ */
244
+ export function createSseUsageScanner(provider) {
245
+ const acc = {};
246
+ let buf = "";
247
+ // When a line has already exceeded the cap, keep discarding until its newline.
248
+ let skipping = false;
249
+ const handleLine = (raw) => {
250
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
251
+ if (!line.startsWith("data:"))
252
+ return; // event:/id:/retry:/comments
253
+ const payload = line.slice(5).trim();
254
+ if (!payload || payload === "[DONE]")
255
+ return;
256
+ if (payload.charCodeAt(0) !== 123 /* { */)
257
+ return;
258
+ try {
259
+ const chunk = JSON.parse(payload);
260
+ const attrs = llmStreamChunkAttrs(provider, chunk);
261
+ for (const k of Object.keys(attrs))
262
+ acc[k] = attrs[k];
263
+ }
264
+ catch {
265
+ // not JSON — ignore this line
266
+ }
267
+ };
268
+ return {
269
+ feed(text) {
270
+ try {
271
+ let start = 0;
272
+ for (;;) {
273
+ const nl = text.indexOf("\n", start);
274
+ if (nl === -1) {
275
+ const rest = text.slice(start);
276
+ if (skipping)
277
+ break;
278
+ if (buf.length + rest.length > SSE_MAX_LINE_BYTES) {
279
+ buf = "";
280
+ skipping = true;
281
+ break;
282
+ }
283
+ buf += rest;
284
+ break;
285
+ }
286
+ const piece = text.slice(start, nl);
287
+ start = nl + 1;
288
+ if (skipping) {
289
+ skipping = false;
290
+ buf = "";
291
+ continue;
292
+ }
293
+ if (buf.length + piece.length > SSE_MAX_LINE_BYTES) {
294
+ buf = "";
295
+ continue; // oversize line — skip it whole
296
+ }
297
+ const line = buf + piece;
298
+ buf = "";
299
+ handleLine(line);
300
+ }
301
+ }
302
+ catch {
303
+ // never throw into the caller's stream
304
+ }
305
+ },
306
+ result() {
307
+ if (buf && !skipping) {
308
+ const line = buf;
309
+ buf = "";
310
+ handleLine(line);
311
+ }
312
+ return { ...acc };
313
+ },
314
+ };
315
+ }
316
+ /**
317
+ * Drain a (teed) SSE body through the scanner and resolve with the accumulated
318
+ * metadata attrs when the stream closes. Resolves — never rejects — on a stream
319
+ * error, returning whatever was scanned so far. Reads chunk-by-chunk with a
320
+ * streaming `TextDecoder`, so the body is never buffered.
321
+ */
322
+ export async function scanSseStreamForUsage(stream, provider) {
323
+ const scanner = createSseUsageScanner(provider);
324
+ const decoder = new TextDecoder();
325
+ const reader = stream.getReader();
326
+ try {
327
+ for (;;) {
328
+ const { done, value } = await reader.read();
329
+ if (done)
330
+ break;
331
+ if (value)
332
+ scanner.feed(decoder.decode(value, { stream: true }));
333
+ }
334
+ scanner.feed(decoder.decode());
335
+ }
336
+ catch {
337
+ // upstream error/cancel — keep what we have
338
+ }
339
+ finally {
340
+ try {
341
+ reader.releaseLock();
342
+ }
343
+ catch {
344
+ // ignore
345
+ }
346
+ }
347
+ return scanner.result();
348
+ }
76
349
  /** Content attrs, ONLY when captureContent is on. Size-capped per value, redactor applied. */
77
350
  export function llmContentAttrs(provider, reqBody, resBody, opts) {
78
351
  const out = {};
package/dist/node.js CHANGED
@@ -3,7 +3,7 @@ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
3
3
  import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
4
4
  import { diag, DiagConsoleLogger, DiagLogLevel } from "@opentelemetry/api";
5
5
  import { ExportResultCode } from "@opentelemetry/core";
6
- import { Resource } from "@opentelemetry/resources";
6
+ import { resourceFromAttributes } from "@opentelemetry/resources";
7
7
  import { buildExporterConfig, releaseResourceAttributes, } from "./core.js";
8
8
  import { isSelfSpanAttrs, safeHostname } from "./self-span.js";
9
9
  /**
@@ -132,7 +132,7 @@ export function initHeystack(o) {
132
132
  // Release/commit attribution. NodeSDK merges `serviceName` on top of this
133
133
  // resource, so the final resource carries service.name + (when provided)
134
134
  // service.version + service.build. Empty when neither option nor env is set.
135
- resource: new Resource(resolveNodeReleaseAttributes(o)),
135
+ resource: resourceFromAttributes(resolveNodeReleaseAttributes(o)),
136
136
  traceExporter,
137
137
  instrumentations,
138
138
  });
@@ -10,7 +10,7 @@ export interface InstrumentBindingsOpts {
10
10
  */
11
11
  startSpan: (name: string, attrs: Record<string, unknown>, kind?: SpanKind) => Span;
12
12
  /**
13
- * `true` → auto-detect and wrap all D1/KV/R2/Vectorize/AI/Queue/Service bindings.
13
+ * `true` → auto-detect and wrap all supported Cloudflare bindings.
14
14
  * `string[]` → only wrap bindings whose env key is listed.
15
15
  */
16
16
  select: boolean | string[];
@@ -34,6 +34,9 @@ export declare function injectTraceparentArgs(args: any[], traceparent: string):
34
34
  * - Vectorize: `query`+`upsert`
35
35
  * - Workers AI: `run` (without `prepare`)
36
36
  * - Queue producer: `send`+`sendBatch`
37
+ * - Workflow: `get`+`create`+`createBatch`
38
+ * - Rate limit: `limit`
39
+ * - Analytics Engine: `writeDataPoint`
37
40
  * - Service binding / Fetcher: `fetch` (last branch; most generic)
38
41
  *
39
42
  * Unrecognised bindings are passed through unchanged.
@@ -1,9 +1,8 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // Cloudflare binding instrumentation for @heystack/otel/workers.
3
3
  //
4
- // Wraps D1, KV, R2, Vectorize, Workers AI, Queue producers, and Service
5
- // bindings with OTel child spans so that every binding operation is visible
6
- // as a child of the active request span.
4
+ // Wraps stable Cloudflare binding APIs with OTel child spans so that binding
5
+ // operations are visible as children of the active request span.
7
6
  //
8
7
  // WinterCG-safe: no `node:*` imports. Span factory is injected so the logic
9
8
  // is pure and unit-testable without a global provider.
@@ -45,6 +44,17 @@ function isQueueLike(b) {
45
44
  return (typeof b?.send === "function" &&
46
45
  typeof b?.sendBatch === "function");
47
46
  }
47
+ function isWorkflowLike(b) {
48
+ return (typeof b?.get === "function" &&
49
+ typeof b?.create === "function" &&
50
+ typeof b?.createBatch === "function");
51
+ }
52
+ function isRateLimitLike(b) {
53
+ return typeof b?.limit === "function";
54
+ }
55
+ function isAnalyticsEngineLike(b) {
56
+ return typeof b?.writeDataPoint === "function";
57
+ }
48
58
  /** Service binding / Fetcher: last branch — anything with `.fetch`. Prior branches
49
59
  * exclude D1/R2/KV/Vectorize/AI/Queue, so order alone is sufficient. */
50
60
  function isServiceLike(b) {
@@ -69,12 +79,31 @@ async function runWithSpan(span, fn) {
69
79
  span.end();
70
80
  }
71
81
  }
82
+ function runSyncWithSpan(span, fn) {
83
+ try {
84
+ return fn();
85
+ }
86
+ catch (err) {
87
+ span.recordException(err instanceof Error ? err : new Error(String(err)));
88
+ span.setStatus({
89
+ code: SpanStatusCode.ERROR,
90
+ message: err instanceof Error ? err.message : String(err),
91
+ });
92
+ throw err;
93
+ }
94
+ finally {
95
+ span.end();
96
+ }
97
+ }
72
98
  // ---------------------------------------------------------------------------
73
99
  // Proxy helper — intercepts listed handlers, falls through to prototype for rest
74
100
  // ---------------------------------------------------------------------------
101
+ const rawTarget = Symbol("heystack.rawBindingTarget");
75
102
  function makeProxy(target, handlers) {
76
103
  return new Proxy(target, {
77
104
  get(t, prop, receiver) {
105
+ if (prop === rawTarget)
106
+ return t;
78
107
  if (typeof prop === "string" && prop in handlers)
79
108
  return handlers[prop];
80
109
  const val = Reflect.get(t, prop, receiver);
@@ -82,6 +111,9 @@ function makeProxy(target, handlers) {
82
111
  },
83
112
  });
84
113
  }
114
+ function unwrapTarget(value) {
115
+ return (value?.[rawTarget] ?? value);
116
+ }
85
117
  // ---------------------------------------------------------------------------
86
118
  // D1 wrappers
87
119
  // ---------------------------------------------------------------------------
@@ -112,6 +144,47 @@ function wrapD1(binding, opts) {
112
144
  prepare(sql) {
113
145
  return wrapD1Statement(binding.prepare(sql), sql, opts);
114
146
  },
147
+ batch(statements) {
148
+ if (isTracingSuppressed(context.active())) {
149
+ return binding.batch(statements.map(unwrapTarget));
150
+ }
151
+ const span = opts.startSpan("D1 batch", {
152
+ "db.system": "d1",
153
+ "db.operation.name": "batch",
154
+ "db.operation.batch.size": statements.length,
155
+ });
156
+ return runWithSpan(span, () => binding.batch(statements.map(unwrapTarget)));
157
+ },
158
+ exec(query) {
159
+ if (isTracingSuppressed(context.active()))
160
+ return binding.exec(query);
161
+ const span = opts.startSpan("D1 exec", {
162
+ "db.system": "d1",
163
+ "db.operation.name": "exec",
164
+ });
165
+ return runWithSpan(span, () => binding.exec(query));
166
+ },
167
+ withSession(...args) {
168
+ return wrapD1Session(binding.withSession(...args), opts);
169
+ },
170
+ });
171
+ }
172
+ function wrapD1Session(session, opts) {
173
+ return makeProxy(session, {
174
+ prepare(sql) {
175
+ return wrapD1Statement(session.prepare(sql), sql, opts);
176
+ },
177
+ batch(statements) {
178
+ if (isTracingSuppressed(context.active())) {
179
+ return session.batch(statements.map(unwrapTarget));
180
+ }
181
+ const span = opts.startSpan("D1 session batch", {
182
+ "db.system": "d1",
183
+ "db.operation.name": "batch",
184
+ "db.operation.batch.size": statements.length,
185
+ });
186
+ return runWithSpan(span, () => session.batch(statements.map(unwrapTarget)));
187
+ },
115
188
  });
116
189
  }
117
190
  // ---------------------------------------------------------------------------
@@ -131,6 +204,7 @@ function wrapKV(binding, opts, namespace) {
131
204
  };
132
205
  return makeProxy(binding, {
133
206
  get: wrapOp("get", (key) => key),
207
+ getWithMetadata: wrapOp("getWithMetadata", (key) => key),
134
208
  put: wrapOp("put", (key) => key),
135
209
  list: wrapOp("list"),
136
210
  delete: wrapOp("delete", (key) => key),
@@ -151,11 +225,56 @@ function wrapR2(binding, opts, bucket) {
151
225
  const span = opts.startSpan(`R2 ${op}`, attrs);
152
226
  return runWithSpan(span, () => binding[op](...args));
153
227
  };
228
+ const wrapMultipart = (upload, key) => makeProxy(upload, {
229
+ uploadPart: async (partNumber, ...args) => {
230
+ if (isTracingSuppressed(context.active())) {
231
+ return upload.uploadPart(partNumber, ...args);
232
+ }
233
+ const span = opts.startSpan("R2 uploadPart", {
234
+ "r2.bucket": bucket,
235
+ "r2.key": key,
236
+ "r2.multipart.part_number": partNumber,
237
+ });
238
+ return runWithSpan(span, () => upload.uploadPart(partNumber, ...args));
239
+ },
240
+ abort: wrapMultipartOp("abort", upload, key),
241
+ complete: wrapMultipartOp("complete", upload, key, (parts) => ({
242
+ "r2.multipart.part_count": parts.length,
243
+ })),
244
+ });
245
+ function wrapMultipartOp(op, upload, key, extraAttrs) {
246
+ return async (...args) => {
247
+ if (isTracingSuppressed(context.active()))
248
+ return upload[op](...args);
249
+ const span = opts.startSpan(`R2 ${op}`, {
250
+ "r2.bucket": bucket,
251
+ "r2.key": key,
252
+ ...extraAttrs?.(...args),
253
+ });
254
+ return runWithSpan(span, () => upload[op](...args));
255
+ };
256
+ }
154
257
  return makeProxy(binding, {
155
258
  get: wrapOp("get", (key) => key),
156
259
  put: wrapOp("put", (key) => key),
157
260
  head: wrapOp("head", (key) => key),
158
261
  delete: wrapOp("delete", (key) => key),
262
+ list: wrapOp("list"),
263
+ async createMultipartUpload(key, ...args) {
264
+ if (isTracingSuppressed(context.active())) {
265
+ const upload = await binding.createMultipartUpload(key, ...args);
266
+ return wrapMultipart(upload, key);
267
+ }
268
+ const span = opts.startSpan("R2 createMultipartUpload", {
269
+ "r2.bucket": bucket,
270
+ "r2.key": key,
271
+ });
272
+ const upload = await runWithSpan(span, () => binding.createMultipartUpload(key, ...args));
273
+ return wrapMultipart(upload, key);
274
+ },
275
+ resumeMultipartUpload(key, uploadId) {
276
+ return wrapMultipart(binding.resumeMultipartUpload(key, uploadId), key);
277
+ },
159
278
  });
160
279
  }
161
280
  // ---------------------------------------------------------------------------
@@ -261,6 +380,66 @@ function wrapQueue(binding, opts, name) {
261
380
  const span = opts.startSpan("Queue sendBatch", batchAttrs, SpanKind.PRODUCER);
262
381
  return runWithSpan(span, () => binding.sendBatch(...args));
263
382
  },
383
+ metrics: async (...args) => {
384
+ if (isTracingSuppressed(context.active()))
385
+ return binding.metrics(...args);
386
+ const span = opts.startSpan("Queue metrics", {
387
+ ...baseAttrs,
388
+ "messaging.operation": "metrics",
389
+ });
390
+ return runWithSpan(span, () => binding.metrics(...args));
391
+ },
392
+ });
393
+ }
394
+ // ---------------------------------------------------------------------------
395
+ // Workflow, rate-limit, and Analytics Engine bindings
396
+ // ---------------------------------------------------------------------------
397
+ function wrapWorkflow(binding, opts, name) {
398
+ const wrapOp = (op, kind, getCount) => async (...args) => {
399
+ if (isTracingSuppressed(context.active()))
400
+ return binding[op](...args);
401
+ const attrs = {
402
+ "cloudflare.binding.type": "workflow",
403
+ "cloudflare.binding.name": name,
404
+ "cloudflare.workflow.operation": op,
405
+ };
406
+ const count = getCount?.(...args);
407
+ if (count !== undefined)
408
+ attrs["cloudflare.workflow.batch.size"] = count;
409
+ const span = opts.startSpan(`Workflow ${op}`, attrs, kind);
410
+ return runWithSpan(span, () => binding[op](...args));
411
+ };
412
+ return makeProxy(binding, {
413
+ get: wrapOp("get", SpanKind.CLIENT),
414
+ create: wrapOp("create", SpanKind.PRODUCER),
415
+ createBatch: wrapOp("createBatch", SpanKind.PRODUCER, (batch) => batch.length),
416
+ deleteBatch: wrapOp("deleteBatch", SpanKind.CLIENT, (ids) => ids.length),
417
+ });
418
+ }
419
+ function wrapRateLimit(binding, opts, name) {
420
+ return makeProxy(binding, {
421
+ limit: async (...args) => {
422
+ if (isTracingSuppressed(context.active()))
423
+ return binding.limit(...args);
424
+ const span = opts.startSpan("RateLimit limit", {
425
+ "cloudflare.binding.type": "rate_limit",
426
+ "cloudflare.binding.name": name,
427
+ });
428
+ return runWithSpan(span, () => binding.limit(...args));
429
+ },
430
+ });
431
+ }
432
+ function wrapAnalyticsEngine(binding, opts, name) {
433
+ return makeProxy(binding, {
434
+ writeDataPoint(...args) {
435
+ if (isTracingSuppressed(context.active()))
436
+ return binding.writeDataPoint(...args);
437
+ const span = opts.startSpan("AnalyticsEngine writeDataPoint", {
438
+ "cloudflare.binding.type": "analytics_engine",
439
+ "cloudflare.binding.name": name,
440
+ });
441
+ return runSyncWithSpan(span, () => binding.writeDataPoint(...args));
442
+ },
264
443
  });
265
444
  }
266
445
  // ---------------------------------------------------------------------------
@@ -315,6 +494,9 @@ function wrapService(binding, opts, name) {
315
494
  * - Vectorize: `query`+`upsert`
316
495
  * - Workers AI: `run` (without `prepare`)
317
496
  * - Queue producer: `send`+`sendBatch`
497
+ * - Workflow: `get`+`create`+`createBatch`
498
+ * - Rate limit: `limit`
499
+ * - Analytics Engine: `writeDataPoint`
318
500
  * - Service binding / Fetcher: `fetch` (last branch; most generic)
319
501
  *
320
502
  * Unrecognised bindings are passed through unchanged.
@@ -356,6 +538,15 @@ export function instrumentEnv(env, opts) {
356
538
  else if (isQueueLike(binding)) {
357
539
  result[key] = wrapQueue(binding, opts, key);
358
540
  }
541
+ else if (isWorkflowLike(binding)) {
542
+ result[key] = wrapWorkflow(binding, opts, key);
543
+ }
544
+ else if (isRateLimitLike(binding)) {
545
+ result[key] = wrapRateLimit(binding, opts, key);
546
+ }
547
+ else if (isAnalyticsEngineLike(binding)) {
548
+ result[key] = wrapAnalyticsEngine(binding, opts, key);
549
+ }
359
550
  else if (isServiceLike(binding)) {
360
551
  result[key] = wrapService(binding, opts, key);
361
552
  }