@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.
- package/README.md +102 -20
- package/dist/llm-enrich.d.ts +63 -1
- package/dist/llm-enrich.js +292 -19
- package/dist/node.js +2 -2
- package/dist/workers-bindings.d.ts +4 -1
- package/dist/workers-bindings.js +194 -3
- package/dist/workers.d.ts +31 -3
- package/dist/workers.js +278 -68
- package/package.json +8 -8
package/dist/llm-enrich.js
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (typeof
|
|
53
|
-
out["gen_ai.usage.
|
|
54
|
-
|
|
55
|
-
|
|
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:
|
|
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 {
|
|
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:
|
|
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
|
|
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.
|
package/dist/workers-bindings.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
2
|
// Cloudflare binding instrumentation for @heystack/otel/workers.
|
|
3
3
|
//
|
|
4
|
-
// Wraps
|
|
5
|
-
//
|
|
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
|
}
|