@heystack/otel 0.12.0 → 0.14.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 +130 -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-agents.d.ts +49 -0
- package/dist/workers-agents.js +110 -0
- package/dist/workers.d.ts +61 -3
- package/dist/workers.js +442 -63
- package/package.json +12 -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
|
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type Span } from "@opentelemetry/api";
|
|
2
|
+
import { type CloudflareNativeTracing } from "./workers";
|
|
3
|
+
/** Structural equivalent of the Agents SDK event; no `agents` peer required. */
|
|
4
|
+
export interface CloudflareAgentEvent {
|
|
5
|
+
type: string;
|
|
6
|
+
agent?: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
payload?: Record<string, unknown>;
|
|
9
|
+
timestamp?: number;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
export interface CloudflareAgentsObservability {
|
|
13
|
+
emit(event: CloudflareAgentEvent): void;
|
|
14
|
+
}
|
|
15
|
+
export interface CloudflareAgentsObservabilityOptions {
|
|
16
|
+
/** Static fields added to every structured event and active-span event. */
|
|
17
|
+
attributes?: Record<string, string | number | boolean>;
|
|
18
|
+
/** Allow-list event types. By default every Agents SDK event is retained. */
|
|
19
|
+
include?: readonly string[];
|
|
20
|
+
/** Additional payload keys to redact. Common secret keys are always redacted. */
|
|
21
|
+
redactKeys?: readonly string[];
|
|
22
|
+
/** Override the structured-log sink (useful for tests/custom routing). */
|
|
23
|
+
logger?: (level: "info" | "error", value: Record<string, unknown>) => void;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Agents SDK `Observability` implementation. It emits one structured log for
|
|
27
|
+
* every lifecycle event and mirrors safe scalar metadata onto the active span.
|
|
28
|
+
* Native Workers log export then preserves trace correlation without an extra
|
|
29
|
+
* network request in the Agent/Durable Object request path.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createCloudflareAgentsObservability(options?: CloudflareAgentsObservabilityOptions): CloudflareAgentsObservability;
|
|
32
|
+
export interface CloudflareDiagnosticsMessage {
|
|
33
|
+
timestamp?: number;
|
|
34
|
+
channel?: string;
|
|
35
|
+
message?: CloudflareAgentEvent | unknown;
|
|
36
|
+
}
|
|
37
|
+
export interface CloudflareTailEvent {
|
|
38
|
+
diagnosticsChannelEvents?: CloudflareDiagnosticsMessage[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Tail Worker handler for production Agents diagnostics channels. Attach the
|
|
42
|
+
* returned handler as a Tail Consumer; its structured logs are exported by the
|
|
43
|
+
* Tail Worker's own native OTLP logs destination.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createCloudflareAgentsTailHandler(options?: CloudflareAgentsObservabilityOptions): {
|
|
46
|
+
tail(events: CloudflareTailEvent[]): Promise<void>;
|
|
47
|
+
};
|
|
48
|
+
/** Explicit async-safe native custom span for Agent model/tool/RPC work. */
|
|
49
|
+
export declare function withCloudflareAgentSpan<T>(tracing: ExecutionContext | CloudflareNativeTracing, operation: string, attrs: Record<string, string | number | boolean>, fn: (span: Span) => T | Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { trace } from "@opentelemetry/api";
|
|
2
|
+
import { withCloudflareSpan, } from "./workers";
|
|
3
|
+
const SENSITIVE_KEY = /(?:authorization|cookie|password|secret|token|api[_-]?key|prompt|completion|content|message)/i;
|
|
4
|
+
const ERROR_EVENT = /(?::error|:failed|:stalled|:interrupted|:exhausted)$/;
|
|
5
|
+
function sanitize(value, extraKeys, depth = 0) {
|
|
6
|
+
if (depth > 5)
|
|
7
|
+
return "[truncated]";
|
|
8
|
+
if (value === null || typeof value === "boolean" || typeof value === "number")
|
|
9
|
+
return value;
|
|
10
|
+
if (typeof value === "string")
|
|
11
|
+
return value.length > 2_000 ? `${value.slice(0, 2_000)}…` : value;
|
|
12
|
+
if (Array.isArray(value))
|
|
13
|
+
return value.slice(0, 50).map((item) => sanitize(item, extraKeys, depth + 1));
|
|
14
|
+
if (typeof value === "object") {
|
|
15
|
+
const out = {};
|
|
16
|
+
for (const [key, item] of Object.entries(value).slice(0, 100)) {
|
|
17
|
+
out[key] = SENSITIVE_KEY.test(key) || extraKeys.has(key) ? "[redacted]" : sanitize(item, extraKeys, depth + 1);
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
return String(value);
|
|
22
|
+
}
|
|
23
|
+
function eventAttributes(event) {
|
|
24
|
+
const attrs = { "cloudflare.agent.event.type": event.type };
|
|
25
|
+
if (event.agent)
|
|
26
|
+
attrs["cloudflare.agent.class"] = event.agent;
|
|
27
|
+
if (event.name)
|
|
28
|
+
attrs["cloudflare.agent.name"] = event.name;
|
|
29
|
+
if (event.timestamp)
|
|
30
|
+
attrs["cloudflare.agent.event.timestamp_ms"] = event.timestamp;
|
|
31
|
+
for (const [key, value] of Object.entries(event.payload ?? {})) {
|
|
32
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
33
|
+
if (!SENSITIVE_KEY.test(key))
|
|
34
|
+
attrs[`cloudflare.agent.${key}`] = value;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return attrs;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Agents SDK `Observability` implementation. It emits one structured log for
|
|
41
|
+
* every lifecycle event and mirrors safe scalar metadata onto the active span.
|
|
42
|
+
* Native Workers log export then preserves trace correlation without an extra
|
|
43
|
+
* network request in the Agent/Durable Object request path.
|
|
44
|
+
*/
|
|
45
|
+
export function createCloudflareAgentsObservability(options = {}) {
|
|
46
|
+
const include = options.include ? new Set(options.include) : undefined;
|
|
47
|
+
const redactKeys = new Set(options.redactKeys ?? []);
|
|
48
|
+
const logger = options.logger ?? ((level, value) => {
|
|
49
|
+
if (level === "error")
|
|
50
|
+
console.error(value);
|
|
51
|
+
else
|
|
52
|
+
console.log(value);
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
emit(event) {
|
|
56
|
+
if (!event?.type || (include && !include.has(event.type)))
|
|
57
|
+
return;
|
|
58
|
+
const level = ERROR_EVENT.test(event.type) ? "error" : "info";
|
|
59
|
+
const safeEvent = sanitize(event, redactKeys);
|
|
60
|
+
const value = {
|
|
61
|
+
event: "cloudflare.agent",
|
|
62
|
+
...options.attributes,
|
|
63
|
+
...safeEvent,
|
|
64
|
+
severity: level,
|
|
65
|
+
};
|
|
66
|
+
logger(level, value);
|
|
67
|
+
const active = trace.getActiveSpan();
|
|
68
|
+
if (active?.isRecording())
|
|
69
|
+
active.addEvent(`cloudflare.agent.${event.type}`, {
|
|
70
|
+
...options.attributes,
|
|
71
|
+
...eventAttributes(event),
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Tail Worker handler for production Agents diagnostics channels. Attach the
|
|
78
|
+
* returned handler as a Tail Consumer; its structured logs are exported by the
|
|
79
|
+
* Tail Worker's own native OTLP logs destination.
|
|
80
|
+
*/
|
|
81
|
+
export function createCloudflareAgentsTailHandler(options = {}) {
|
|
82
|
+
const observability = createCloudflareAgentsObservability(options);
|
|
83
|
+
return {
|
|
84
|
+
async tail(events) {
|
|
85
|
+
for (const event of events) {
|
|
86
|
+
for (const diagnostic of event.diagnosticsChannelEvents ?? []) {
|
|
87
|
+
if (!diagnostic.channel?.startsWith("agents:"))
|
|
88
|
+
continue;
|
|
89
|
+
const message = diagnostic.message;
|
|
90
|
+
if (!message || typeof message !== "object")
|
|
91
|
+
continue;
|
|
92
|
+
const agentEvent = message;
|
|
93
|
+
observability.emit({
|
|
94
|
+
...agentEvent,
|
|
95
|
+
type: agentEvent.type || diagnostic.channel.slice("agents:".length),
|
|
96
|
+
timestamp: agentEvent.timestamp ?? diagnostic.timestamp,
|
|
97
|
+
diagnostics_channel: diagnostic.channel,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** Explicit async-safe native custom span for Agent model/tool/RPC work. */
|
|
105
|
+
export function withCloudflareAgentSpan(tracing, operation, attrs, fn) {
|
|
106
|
+
return withCloudflareSpan(tracing, `agent.${operation}`, {
|
|
107
|
+
"cloudflare.agent.operation": operation,
|
|
108
|
+
...attrs,
|
|
109
|
+
}, fn);
|
|
110
|
+
}
|
package/dist/workers.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type Span } from "@opentelemetry/api";
|
|
|
2
2
|
import { type Context, type ContextManager } from "@opentelemetry/api";
|
|
3
3
|
import { BasicTracerProvider, type ReadableSpan, type SpanExporter } from "@opentelemetry/sdk-trace-base";
|
|
4
4
|
import { type HeystackOptions } from "./core.js";
|
|
5
|
+
import { type LLMGatewayConfig } from "./llm-enrich.js";
|
|
5
6
|
declare const ExportResultCode: {
|
|
6
7
|
readonly SUCCESS: 0;
|
|
7
8
|
readonly FAILED: 1;
|
|
@@ -171,6 +172,17 @@ export interface WorkersConfig {
|
|
|
171
172
|
* Defaults to `false` (no binding tracing). Consumed by a later task.
|
|
172
173
|
*/
|
|
173
174
|
instrumentBindings?: boolean | string[];
|
|
175
|
+
/**
|
|
176
|
+
* Select the Workers tracing plane. `auto` (default) uses Cloudflare's native
|
|
177
|
+
* tracer when `ctx.tracing` is available and otherwise falls back to direct
|
|
178
|
+
* OTLP export. `native` prefers the platform tracer but still fails open when
|
|
179
|
+
* unavailable. `direct` always uses Heystack's fetch exporter.
|
|
180
|
+
*
|
|
181
|
+
* Native mode deliberately disables Heystack root/fetch/binding
|
|
182
|
+
* instrumentation: Cloudflare already emits those spans, and running both
|
|
183
|
+
* creates duplicate, disconnected trace trees.
|
|
184
|
+
*/
|
|
185
|
+
nativeTracing?: "auto" | "native" | "direct";
|
|
174
186
|
/**
|
|
175
187
|
* Head-sampling configuration. When omitted (or `rate` >= 1), all traces are
|
|
176
188
|
* kept. When `rate` < 1, a deterministic FNV-1a hash of the trace ID decides
|
|
@@ -192,20 +204,45 @@ export interface WorkersConfig {
|
|
|
192
204
|
};
|
|
193
205
|
/**
|
|
194
206
|
* LLM/gen_ai capture for outbound calls to known providers (OpenAI, Anthropic,
|
|
195
|
-
* CF AI Gateway
|
|
196
|
-
* always captured for detected providers
|
|
207
|
+
* Mistral, Google, CF AI Gateway). Metadata (model, tokens, latency,
|
|
208
|
+
* finish_reason) is always captured for detected providers — including
|
|
209
|
+
* streamed (`text/event-stream`) responses, whose usage is lifted from the
|
|
210
|
+
* stream without buffering content. Works on both tracing planes: as a CLIENT
|
|
211
|
+
* span in direct mode and as a native `<operation> <provider>` span (e.g.
|
|
212
|
+
* `chat mistral`) under Cloudflare native tracing.
|
|
213
|
+
*
|
|
214
|
+
* `gateways` teaches the detector about AI gateways / proxies you call
|
|
215
|
+
* instead of the provider host (Wafer, a corporate proxy, …): each entry
|
|
216
|
+
* names the gateway host and a path pattern whose `:provider` segment carries
|
|
217
|
+
* the provider token (`openai | anthropic | google | gemini | mistral |
|
|
218
|
+
* cloudflare | workers-ai`). Resolved calls get `gen_ai.system=<provider>` +
|
|
219
|
+
* `heystack.ai.gateway=<host>`. Example for Wafer:
|
|
220
|
+
* `gateways: [{ hostSuffix: "wafersecurity.ai", pathPattern: "/p/:tenant/:provider" }]`
|
|
197
221
|
*
|
|
198
222
|
* Set `captureContent: true` to also capture prompt/completion text (off by
|
|
199
223
|
* default) — strongly recommended for AI-app RCA. Provide `redact` to scrub
|
|
200
224
|
* sensitive text before it leaves the Worker. `maxContentChars` caps the length
|
|
201
|
-
* of each captured value (default 8000 characters).
|
|
225
|
+
* of each captured value (default 8000 characters). Content is never captured
|
|
226
|
+
* from streamed responses.
|
|
202
227
|
*/
|
|
203
228
|
ai?: {
|
|
204
229
|
captureContent?: boolean;
|
|
205
230
|
redact?: (text: string) => string;
|
|
206
231
|
maxContentChars?: number;
|
|
232
|
+
gateways?: LLMGatewayConfig[];
|
|
207
233
|
};
|
|
208
234
|
}
|
|
235
|
+
/** Minimal public surface exposed by Cloudflare's Workers custom-spans API. */
|
|
236
|
+
export interface CloudflareNativeSpan {
|
|
237
|
+
readonly isTraced: boolean;
|
|
238
|
+
setAttribute(key: string, value: string | number | boolean | undefined): void;
|
|
239
|
+
end(): void;
|
|
240
|
+
}
|
|
241
|
+
/** `ExecutionContext.tracing` without importing the `cloudflare:workers` module. */
|
|
242
|
+
export interface CloudflareNativeTracing {
|
|
243
|
+
enterSpan<T>(name: string, callback: (span: CloudflareNativeSpan) => T): T;
|
|
244
|
+
startActiveSpan<T>(name: string, callback: (span: CloudflareNativeSpan) => T): T;
|
|
245
|
+
}
|
|
209
246
|
/**
|
|
210
247
|
* A `BasicTracerProvider` with the underlying `HeystackSpanExporter` attached so
|
|
211
248
|
* flush paths can drain its in-flight fetches directly (the OTel processor's
|
|
@@ -388,9 +425,30 @@ export declare function instrument<H extends WorkerHandler<any>>(handler: H, con
|
|
|
388
425
|
*/
|
|
389
426
|
export declare function withSpan<T>(name: string, fn: (span: Span) => T | Promise<T>): Promise<T>;
|
|
390
427
|
export declare function withSpan<T>(name: string, attrs: Record<string, string | number | boolean>, fn: (span: Span) => T | Promise<T>): Promise<T>;
|
|
428
|
+
/**
|
|
429
|
+
* Create a Cloudflare-native custom span using an explicit ExecutionContext.
|
|
430
|
+
* Prefer this form in code that calls `withSpan` after an `await` on a Worker
|
|
431
|
+
* without `nodejs_compat`: the explicit context cannot be lost across async
|
|
432
|
+
* boundaries. It falls back to the regular OTel tracer outside Cloudflare.
|
|
433
|
+
*/
|
|
434
|
+
export declare function withCloudflareSpan<T>(ctx: ExecutionContext | CloudflareNativeTracing, name: string, fn: (span: Span) => T | Promise<T>): Promise<T>;
|
|
435
|
+
export declare function withCloudflareSpan<T>(ctx: ExecutionContext | CloudflareNativeTracing, name: string, attrs: Record<string, string | number | boolean>, fn: (span: Span) => T | Promise<T>): Promise<T>;
|
|
391
436
|
/**
|
|
392
437
|
* Add a named event (with optional attributes) to the currently-active span.
|
|
393
438
|
* No-op when no span is active.
|
|
394
439
|
*/
|
|
395
440
|
export declare function addEvent(name: string, attrs?: Record<string, string | number | boolean>): void;
|
|
396
441
|
export type { Span };
|
|
442
|
+
/**
|
|
443
|
+
* `gen_ai.system` + `gen_ai.request.*` from an LLM request body (model,
|
|
444
|
+
* max_tokens, temperature). Alias of the internal request mapper.
|
|
445
|
+
*/
|
|
446
|
+
export { llmRequestAttrs as genAiRequestAttrs } from "./llm-enrich.js";
|
|
447
|
+
/**
|
|
448
|
+
* `gen_ai.response.*` + `gen_ai.usage.*` from an LLM response object. Understands
|
|
449
|
+
* OpenAI/Mistral (`usage.prompt_tokens`/`completion_tokens`), Anthropic
|
|
450
|
+
* (`usage.input_tokens`/`output_tokens`) and Google (`usageMetadata`) shapes —
|
|
451
|
+
* Workers AI results (`usage.prompt_tokens`/`completion_tokens`) included.
|
|
452
|
+
*/
|
|
453
|
+
export { llmResponseAttrs as genAiResponseAttrs } from "./llm-enrich.js";
|
|
454
|
+
export type { LLMGatewayConfig, LLMProvider } from "./llm-enrich.js";
|