@heystack/otel 0.13.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 +64 -16
- package/dist/llm-enrich.d.ts +63 -1
- package/dist/llm-enrich.js +292 -19
- package/dist/node.js +2 -2
- package/dist/workers.d.ts +31 -3
- package/dist/workers.js +278 -68
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Always read your key from the environment — never paste it into source:
|
|
|
13
13
|
HEYSTACK_API_KEY=sk_live_…
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
> **Use `@heystack/otel` `>=0.
|
|
16
|
+
> **Use `@heystack/otel` `>=0.14.0`.** 0.14 makes LLM `gen_ai.*` telemetry work under Cloudflare native tracing (it was silently absent before), adds Mistral + AI-gateway (`ai.gateways`) detection and streaming token usage, and moves to the OpenTelemetry 2.x SDK (clears downstream Dependabot alerts). 0.13 made Cloudflare's native trace tree authoritative. See [Migration](#migration--versioning) below.
|
|
17
17
|
|
|
18
18
|
## Runtime matrix
|
|
19
19
|
|
|
@@ -127,7 +127,7 @@ head_sampling_rate = 1
|
|
|
127
127
|
persist = false
|
|
128
128
|
```
|
|
129
129
|
|
|
130
|
-
Cloudflare creates the invocation and supported platform/binding spans and exports trace-correlated logs. `instrument()` detects `ctx.tracing` and adds Heystack business/identity attributes to that same tree. In this mode it creates no OTel provider, makes no Heystack export request, does not
|
|
130
|
+
Cloudflare creates the invocation and supported platform/binding spans and exports trace-correlated logs. `instrument()` detects `ctx.tracing` and adds Heystack business/identity attributes to that same tree. In this mode it creates no OTel provider, makes no Heystack export request, does not proxy bindings, and does not require `HEYSTACK_API_KEY` in the Worker. The only `fetch` patch it installs is a **lean, LLM-only** one (0.14+): outbound calls to a detected LLM provider or a configured AI gateway get a native `<operation> <provider>` span (e.g. `chat mistral`) with `gen_ai.*` attributes — every other subrequest passes through untouched, since Cloudflare already records it. See [AI / LLM observability](#ai--llm-observability).
|
|
131
131
|
|
|
132
132
|
```ts
|
|
133
133
|
import { instrument, withCloudflareSpan } from "@heystack/otel/workers";
|
|
@@ -213,7 +213,7 @@ Set the key as a secret: `wrangler secret put HEYSTACK_API_KEY`, and set `native
|
|
|
213
213
|
| `getUser` | `(req: Request) => { id?, session?, requestId? } \| undefined` | Called per request. `id` → `enduser.id`, `session` → `session.id`, `requestId` → `http.request.id` (falls back to the `cf-ray` header). |
|
|
214
214
|
| `instrumentBindings` | `boolean \| string[]` | `true` = auto child spans for all detected D1 / KV / R2 / Vectorize / Workers AI / Queue producer / Service binding bindings; `string[]` = only the named bindings. Default `false`. |
|
|
215
215
|
| `sampling` | `{ rate?: number } \| { remote: true }` | Head-sampling configuration. `{ rate }`: keep a deterministic fraction of fresh root traces (0–1; default `1` = keep all). `{ remote: true }`: fetch the rate from the Heystack config endpoint instead — lets you change it centrally without redeploying. Cold isolates keep all traffic until the first config fetch resolves; fails open if the config can't be reached. Parent-respecting in both modes: a request arriving with a sampled `traceparent` is always recorded. See [Head sampling](#head-sampling) below. |
|
|
216
|
-
| `ai` | `{ captureContent?: boolean; redact?: (text: string) => string; maxContentChars?: number }` | LLM/gen_ai capture for outbound calls to known providers. See [AI / LLM observability](#ai--llm-observability) below. |
|
|
216
|
+
| `ai` | `{ captureContent?: boolean; redact?: (text: string) => string; maxContentChars?: number; gateways?: Array<{ hostSuffix: string; pathPattern: string }> }` | LLM/gen_ai capture for outbound calls to known providers (works under native tracing and the direct exporter). `gateways` declares AI gateways/proxies you call instead of the provider host (e.g. Wafer). See [AI / LLM observability](#ai--llm-observability) below. |
|
|
217
217
|
| `waitUntil` | `(p: Promise<unknown>) => void` | Override the isolate keep-alive hook; defaults to the auto-detected `ctx.waitUntil`. |
|
|
218
218
|
| `version` | `string?` | Release identifier → `service.version` resource attribute. Powers release health + suspect release. See [Release & commit attribution](#release--commit-attribution-version--build). |
|
|
219
219
|
| `build` | `string?` | Commit SHA → `service.build` resource attribute. Powers suspect commit (deep-links to the commit when a repo URL is set in the console). |
|
|
@@ -273,29 +273,50 @@ These attributes are set automatically on every SERVER span from request metadat
|
|
|
273
273
|
|
|
274
274
|
### AI / LLM observability
|
|
275
275
|
|
|
276
|
-
When your Worker calls an LLM API, `instrument()` automatically attaches [OpenTelemetry gen_ai semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) to the outbound
|
|
276
|
+
When your Worker calls an LLM API, `instrument()` automatically attaches [OpenTelemetry gen_ai semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) to the outbound call. No extra code is needed — detection is based on the target URL. It works on **both tracing planes**:
|
|
277
277
|
|
|
278
|
-
**
|
|
278
|
+
- **Cloudflare native tracing** (`nativeTracing: "auto" | "native"`, the default) — the call becomes a native span named `<operation> <provider>` (e.g. `chat mistral`, `chat google`) in Cloudflare's trace tree, carrying the `gen_ai.*` attributes below plus `server.address` and `http.response.status_code`. Non-LLM subrequests are not touched (Cloudflare records them itself). *(0.14+; before that, native mode emitted no `gen_ai.*` at all.)*
|
|
279
|
+
- **Direct exporter** (`nativeTracing: "direct"`) — the attributes land on the outbound CLIENT span.
|
|
280
|
+
|
|
281
|
+
**Detected providers (direct hosts):**
|
|
279
282
|
- **OpenAI** — `api.openai.com`
|
|
280
283
|
- **Anthropic** — `api.anthropic.com`
|
|
281
|
-
- **
|
|
284
|
+
- **Mistral** — `api.mistral.ai`
|
|
282
285
|
- **Google** — `generativelanguage.googleapis.com`
|
|
286
|
+
- **Cloudflare AI Gateway** — any host ending in `gateway.ai.cloudflare.com`
|
|
287
|
+
|
|
288
|
+
**AI gateways / proxies (`ai.gateways`):** if you call providers through a gateway such as [Wafer](https://wafersecurity.ai) instead of the provider host, tell the SDK how to read the provider off the URL. Each entry names the gateway host (exact or any subdomain) and a path pattern whose `:provider` segment carries the provider token — one of `openai`, `anthropic`, `google`, `gemini` (→ `google`), `mistral`, `cloudflare`, `workers-ai` (→ `cloudflare`). Literal segments must match; other `:params` match any segment; the provider's own API path may follow. Direct hosts are checked first; a gateway URL whose token is unknown is left untraced.
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
export default instrument(worker, {
|
|
292
|
+
service: "tp-api",
|
|
293
|
+
ai: {
|
|
294
|
+
gateways: [{ hostSuffix: "wafersecurity.ai", pathPattern: "/p/:tenant/:provider" }],
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
// https://wafersecurity.ai/p/talkpod/mistral/v1/chat/completions
|
|
298
|
+
// → gen_ai.system = "mistral", heystack.ai.gateway = "wafersecurity.ai"
|
|
299
|
+
// https://wafersecurity.ai/p/talkpod/gemini/v1beta/models/gemini-2.5-flash:streamGenerateContent
|
|
300
|
+
// → gen_ai.system = "google", heystack.ai.gateway = "wafersecurity.ai"
|
|
301
|
+
```
|
|
283
302
|
|
|
284
303
|
**Attributes captured automatically (metadata, always on):**
|
|
285
304
|
|
|
286
305
|
| Attribute | Description |
|
|
287
306
|
| --- | --- |
|
|
288
|
-
| `gen_ai.system` | Provider name (`openai`, `anthropic`, `
|
|
307
|
+
| `gen_ai.system` | Provider name (`openai`, `anthropic`, `mistral`, `google`, `cloudflare`) |
|
|
308
|
+
| `gen_ai.operation.name` | `chat`, `embeddings` or `text_completion`, inferred from the request path |
|
|
309
|
+
| `heystack.ai.gateway` | Gateway hostname — only when the provider was resolved via `ai.gateways` |
|
|
289
310
|
| `gen_ai.request.model` | Model from the request body (`body.model`) |
|
|
290
311
|
| `gen_ai.request.max_tokens` | Max tokens from the request (`max_tokens` or `max_completion_tokens`) |
|
|
291
312
|
| `gen_ai.request.temperature` | Temperature from the request |
|
|
292
|
-
| `gen_ai.response.model` | Model from the response
|
|
293
|
-
| `gen_ai.response.id` | Response ID |
|
|
294
|
-
| `gen_ai.usage.input_tokens` | Input/prompt tokens
|
|
295
|
-
| `gen_ai.usage.output_tokens` | Output/completion tokens
|
|
296
|
-
| `gen_ai.response.finish_reason` | Stop reason (`stop`, `end_turn`, etc.) |
|
|
313
|
+
| `gen_ai.response.model` | Model from the response (`model`, or Google's `modelVersion`) |
|
|
314
|
+
| `gen_ai.response.id` | Response ID (`id`, or Google's `responseId`) |
|
|
315
|
+
| `gen_ai.usage.input_tokens` | Input/prompt tokens (`usage.prompt_tokens`, `usage.input_tokens`, or `usageMetadata.promptTokenCount`) |
|
|
316
|
+
| `gen_ai.usage.output_tokens` | Output/completion tokens (`usage.completion_tokens`, `usage.output_tokens`, or `usageMetadata.candidatesTokenCount`) |
|
|
317
|
+
| `gen_ai.response.finish_reason` | Stop reason (`stop`, `end_turn`, `STOP`, etc.) |
|
|
297
318
|
|
|
298
|
-
Streaming responses (`text/event-stream`)
|
|
319
|
+
**Streaming responses (`text/event-stream`)** are enriched too (0.14+). The body is `tee()`d: your handler gets one branch untouched (byte-for-byte, no added latency); the SDK reads the other **without buffering content** — each `data:` line's JSON is inspected only for `usage` / `usageMetadata`, model, id and finish reason, then discarded. That covers Mistral and OpenAI (usage in the final chunk — for OpenAI send `stream_options: { include_usage: true }`), Anthropic (`message_start` input tokens + `message_delta` output tokens) and Google (`usageMetadata` on the last chunk). The span stays open until the stream closes (or errors), so its duration is the true time-to-last-token. Lines over 64 KB are skipped unparsed; a scanner failure never throws into your stream. Content is never captured from streams, even with `captureContent: true`.
|
|
299
320
|
|
|
300
321
|
**Content capture (opt-in, strongly recommended for AI-app RCA):**
|
|
301
322
|
|
|
@@ -324,8 +345,9 @@ When `captureContent` is enabled, two additional attributes appear on the CLIENT
|
|
|
324
345
|
|
|
325
346
|
**Body safety guarantees:**
|
|
326
347
|
- Request body: only read when it is already a plain `string` (the common LLM SDK case). Non-string bodies (streams, FormData, etc.) are never touched.
|
|
327
|
-
- Response body: read only from `response.clone()
|
|
328
|
-
- Parse failures or unexpected shapes degrade silently to the plain
|
|
348
|
+
- Response body: JSON is read only from `response.clone()`; a stream is `tee()`d and your branch is handed back immediately. Either way the response you receive is intact and your handler can call `.json()` / `.text()` / `.body.getReader()` normally.
|
|
349
|
+
- Parse failures or unexpected shapes degrade silently to the plain span — no errors are thrown.
|
|
350
|
+
- Under native tracing the wrapper needs the request's native tracer in the active context, which propagates across `await`s only with `nodejs_compat` (AsyncLocalStorage). Without it an LLM call made after an `await` passes through untraced — the same caveat as `withSpan`. Use `withCloudflareSpan(ctx, …)` + `genAiResponseAttrs` for a manual span if you cannot enable `nodejs_compat`.
|
|
329
351
|
- For a detected provider with a JSON response, the cloned body is parsed before the `fetch()` promise resolves to your handler (the span must close with usage attributes). This adds negligible latency to an already-slow LLM call.
|
|
330
352
|
- The response size guard (512 KB) only applies when the response carries a `content-length` header; a chunked JSON response without one is still read. The known providers bound this via `max_tokens`, so it is safe in practice.
|
|
331
353
|
|
|
@@ -349,10 +371,35 @@ const data = await withSpan("call-llm", async () => {
|
|
|
349
371
|
});
|
|
350
372
|
```
|
|
351
373
|
|
|
352
|
-
`withSpan(name, attrs?, fn)` — runs `fn` inside a new child span parented to the currently-active span. The span is started before `fn` and ended in `finally`; exceptions are recorded on the span and re-thrown. The `fn` receives the live `Span` as its argument.
|
|
374
|
+
`withSpan(name, attrs?, fn)` — runs `fn` inside a new child span parented to the currently-active span. The span is started before `fn` and ended in `finally`; exceptions are recorded on the span and re-thrown. The `fn` receives the live `Span` as its argument, so you can `span.setAttribute(s)` after the work has produced a result. Under Cloudflare native tracing it joins the native tree; otherwise it is an OTel child span.
|
|
353
375
|
|
|
354
376
|
`addEvent(name, attrs?)` — adds a named event to the currently-active span. No-op when no span is active.
|
|
355
377
|
|
|
378
|
+
#### LLM calls the auto-wrapper can't see (`env.AI.run`, SDKs with their own transport)
|
|
379
|
+
|
|
380
|
+
The automatic `gen_ai.*` enrichment hooks `fetch`. Anything that doesn't go through `fetch` — Workers AI (`env.AI.run`), a binding, an SDK with a custom transport — is invisible to it. `withSpan` plus the two pure helpers `genAiRequestAttrs(provider, requestBody)` and `genAiResponseAttrs(provider, result)` (both exported from `/workers`) is the supported path; it works identically under native tracing and the direct exporter. Name the span `<operation> <provider>` so it lines up with the automatic ones:
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
import { instrument, withSpan, genAiRequestAttrs, genAiResponseAttrs } from "@heystack/otel/workers";
|
|
384
|
+
|
|
385
|
+
async function chat(env: Env, prompt: string) {
|
|
386
|
+
const model = "@cf/meta/llama-3.1-8b-instruct";
|
|
387
|
+
return withSpan(
|
|
388
|
+
"chat cloudflare",
|
|
389
|
+
{ ...genAiRequestAttrs("cloudflare", { model, max_tokens: 256 }), "gen_ai.operation.name": "chat" },
|
|
390
|
+
async (span) => {
|
|
391
|
+
const result = await env.AI.run(model, { prompt, max_tokens: 256 });
|
|
392
|
+
// Workers AI returns `usage: { prompt_tokens, completion_tokens }` for most text models;
|
|
393
|
+
// genAiResponseAttrs maps it to gen_ai.usage.input_tokens / output_tokens (no-op if absent).
|
|
394
|
+
span.setAttributes(genAiResponseAttrs("cloudflare", result));
|
|
395
|
+
return result;
|
|
396
|
+
},
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
`genAiRequestAttrs` sets `gen_ai.system` + `gen_ai.request.{model,max_tokens,temperature}`; `genAiResponseAttrs` understands OpenAI/Mistral, Anthropic, Google and Workers AI response shapes and returns `gen_ai.response.{model,id,finish_reason}` + `gen_ai.usage.*`. Neither ever throws or captures prompt/completion text. (If `instrumentBindings` is on under the direct exporter, `env.AI.run` is already traced automatically — use the manual form under native tracing or for other call paths.)
|
|
402
|
+
|
|
356
403
|
### Why a ContextManager
|
|
357
404
|
|
|
358
405
|
`context.with(...)` in OpenTelemetry is a **no-op unless a ContextManager is registered** with the global API. Without one, `suppressTracing()` — the primary defence against the self-trace feedback loop — silently does nothing in production. As of **0.3.2** the SDK registers a ContextManager exactly once at init. With `AsyncLocalStorage` (workerd under `nodejs_compat`, Node) you also get **cross-`await` parent→child span linking** and **per-request context isolation** — concurrent requests no longer share or clobber the active span.
|
|
@@ -545,6 +592,7 @@ As belt-and-suspenders the exporter also drops any span whose HTTP target points
|
|
|
545
592
|
|
|
546
593
|
## Migration / versioning
|
|
547
594
|
|
|
595
|
+
- **`0.14.0`** — **gen_ai enrichment under Cloudflare native tracing, Mistral + AI-gateway detection, streaming usage; OpenTelemetry JS SDK 2.x.** (1) `/workers`: under `nativeTracing: "auto" | "native"` `instrument()` never installed the fetch wrapper, so native-mode Workers emitted **no** `gen_ai.*` telemetry. It now installs a lean LLM-only wrapper that opens a native `<operation> <provider>` span (e.g. `chat mistral`) with request/response `gen_ai.*` for detected providers and passes every other subrequest through untouched. (2) New provider **Mistral** (`api.mistral.ai`) and a new `ai.gateways` option (`[{ hostSuffix, pathPattern }]`, e.g. `{ hostSuffix: "wafersecurity.ai", pathPattern: "/p/:tenant/:provider" }`) that resolves the provider from an AI-gateway URL and stamps `heystack.ai.gateway`; `gemini` → `google`, `workers-ai` → `cloudflare`. New `gen_ai.operation.name`. (3) **Streaming (`text/event-stream`) responses now yield usage**: the body is `tee()`d and scanned line-by-line for `usage`/`usageMetadata`, model, id and finish reason only (never content); the span stays open until the stream closes. Google (`usageMetadata`) and Mistral response shapes added to the JSON path too. (4) New `/workers` exports `genAiRequestAttrs` / `genAiResponseAttrs` for manual `withSpan` spans around `env.AI.run` or any non-`fetch` LLM call. Native `error.type` now keeps the exception class instead of being overwritten with `error`. (5) All `@opentelemetry/*` dependencies move to the 2.x SDK line (`core`/`sdk-trace-base`/`resources` ^2.11, `sdk-node`/`exporter-trace-otlp-http` ^0.222, `auto-instrumentations-node` ^0.80, `semantic-conventions` ^1.43), clearing the downstream Dependabot alerts the 1.x line surfaced through this package (GHSA-8988-4f7v-96qf in `@opentelemetry/core`, GHSA-q7rr-3cgh-j5r3 in `sdk-node` / `auto-instrumentations-node` / `exporter-prometheus`, GHSA-45rx-2jwx-cxfr in `propagator-jaeger`). **Node ≥ 18.19 (or ≥ 20.6) is now required on `/node` and `/next`** — the 2.x SDK dropped Node 14/16. If you pass your own `instrumentations` array to `initHeystack`, upgrade those `@opentelemetry/instrumentation-*` packages to a release that peers on `@opentelemetry/core@^2` (the auto-instrumentations 0.80 set already does). `/workers` is still WinterCG-safe (no `node:*` imports). No other API changes; all new options are optional.
|
|
548
596
|
- **`0.13.0`** — **Cloudflare-native tracing and complete Workers platform coverage.** On runtimes exposing `ctx.tracing`, `/workers` now joins Cloudflare's authoritative invocation trace instead of creating and exporting a duplicate tree. `nativeTracing: "auto"` is the default; `withCloudflareSpan(ctx, ...)` adds async-safe business spans, while fetch, queue, and scheduled handlers are enriched in the native tree. The new `/workers/agents` entry maps Cloudflare Agents observability callbacks and Tail Worker diagnostics to structured, redacted telemetry. Direct OTLP remains available with `nativeTracing: "direct"` for runtimes without the native API. Heystack ingest now preserves full OTLP trace/log fidelity, accepts OTLP metrics, and can import Workers Analytics metrics through the app-scoped Cloudflare connector. Native mode needs no Worker API-key secret and must not be combined with direct export.
|
|
549
597
|
- **`0.11.0`** — **`/web`: browser error + console collection, in-app bug reports, and seekable replay keyframes.** `instrumentWeb` now captures uncaught errors (`window.onerror` + `unhandledrejection`) and `console.error` as OTLP logs by default (`event.name=browser.error` / `browser.console`, with `exception.*` semconv, `url.full`, `session_id`, and the active trace/span ids when `tracing` is on). New options: `errors?: boolean` (default `true`), `captureConsole?: 'error' | 'warn' | false` (default `'error'`). New **`reportBug({ message, email?, context? })`** API — a headless in-app bug reporter that POSTs to `/v1/bug-reports` and auto-attaches the URL, user agent, `session_id`, active `trace_id`, `release`/`build`, and the last ≤20 captured browser errors (also emitted as a `user.bug_report` log); reports triage in the console **Bugs** tab. The `version` / `build` options are now stamped as `service.version` / `service.build` on exported browser logs (release attribution). Console capture is recursion-guarded + rate-capped (no self-export loop, no flood). The recorder also checkpoints a keyframe (~60s) so the console player seeks from the nearest snapshot instead of loading the whole session. No breaking changes; all new options are optional.
|
|
550
598
|
- **`0.10.0`** — **release / commit attribution (`version` + `build`) on every runtime entry.** New optional options: `version` → the `service.version` resource attribute (a release identifier such as `1.4.2` or a git tag), `build` → the `service.build` resource attribute (the commit SHA). They power **release health**, **suspect release**, and **suspect commit** in the console — attributing a regression to the version/commit that introduced it (suspect-commit deep-links to the commit when a repo URL is configured for the app). Wired on `/node`, `/next` (threaded to whichever runtime runs), and `/workers`; accepted on `/web` for API symmetry. `/node` also reads `HEYSTACK_SERVICE_VERSION` / `OTEL_SERVICE_VERSION` and `HEYSTACK_SERVICE_BUILD` as env fallbacks. Both options are optional and only emitted when non-empty. No breaking changes. See [Release & commit attribution](#release--commit-attribution-version--build).
|
package/dist/llm-enrich.d.ts
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
|
-
export type LLMProvider = "openai" | "anthropic" | "cloudflare" | "google";
|
|
1
|
+
export type LLMProvider = "openai" | "anthropic" | "cloudflare" | "google" | "mistral";
|
|
2
2
|
/** Return the provider for a known LLM host, else null. Host is lower-case, no port. */
|
|
3
3
|
export declare function detectLLMProvider(host: string): LLMProvider | null;
|
|
4
|
+
export interface LLMGatewayConfig {
|
|
5
|
+
/**
|
|
6
|
+
* Gateway hostname. Matches the exact host or any subdomain of it
|
|
7
|
+
* (`wafersecurity.ai` matches `wafersecurity.ai` and `gw.wafersecurity.ai`,
|
|
8
|
+
* never `notwafersecurity.ai`). A leading dot is tolerated.
|
|
9
|
+
*/
|
|
10
|
+
hostSuffix: string;
|
|
11
|
+
/**
|
|
12
|
+
* Path pattern whose `:provider` segment names the upstream provider, e.g.
|
|
13
|
+
* `/p/:tenant/:provider`. Literal segments must match exactly; other `:param`
|
|
14
|
+
* segments match any single segment; the request path may carry further
|
|
15
|
+
* segments after the pattern (the provider's own API path).
|
|
16
|
+
*/
|
|
17
|
+
pathPattern: string;
|
|
18
|
+
}
|
|
19
|
+
export interface ResolvedLLMProvider {
|
|
20
|
+
provider: LLMProvider;
|
|
21
|
+
/** Set (to the gateway hostname) when the provider was resolved via a gateway. */
|
|
22
|
+
gateway?: string;
|
|
23
|
+
}
|
|
24
|
+
/** Map a gateway path token to a provider. `gemini` → google, `workers-ai` → cloudflare. */
|
|
25
|
+
export declare function providerFromToken(token: string): LLMProvider | null;
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the LLM provider for an outbound URL: direct host table first, then
|
|
28
|
+
* each configured gateway pattern. Returns null when neither matches. Pure and
|
|
29
|
+
* exception-free (a malformed gateway entry is skipped, never thrown).
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveLLMProvider(url: URL, gateways?: readonly LLMGatewayConfig[]): ResolvedLLMProvider | null;
|
|
32
|
+
/**
|
|
33
|
+
* Best-effort `gen_ai.operation.name` from the request path. Chat-style
|
|
34
|
+
* endpoints (OpenAI/Mistral `chat/completions`, Anthropic `messages`, Google
|
|
35
|
+
* `*generateContent`) → `chat`; `embeddings` → `embeddings`; bare/fim
|
|
36
|
+
* `completions` → `text_completion`; anything else defaults to `chat`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function llmOperationName(pathname: string): "chat" | "embeddings" | "text_completion";
|
|
4
39
|
export interface LLMRequestAttrs {
|
|
5
40
|
[k: string]: string | number;
|
|
6
41
|
}
|
|
@@ -8,6 +43,33 @@ export interface LLMRequestAttrs {
|
|
|
8
43
|
export declare function llmRequestAttrs(provider: LLMProvider, body: unknown): LLMRequestAttrs;
|
|
9
44
|
/** Best-effort gen_ai.response.* + usage from a parsed JSON response body. Never throws. */
|
|
10
45
|
export declare function llmResponseAttrs(provider: LLMProvider, body: unknown): Record<string, string | number>;
|
|
46
|
+
/** Lines longer than this are skipped unparsed (a bound on per-chunk JSON.parse). */
|
|
47
|
+
export declare const SSE_MAX_LINE_BYTES: number;
|
|
48
|
+
/**
|
|
49
|
+
* Metadata attrs from ONE parsed stream chunk. Shares the response mapper and
|
|
50
|
+
* adds the Anthropic envelope (`message.{id,model,usage}` on `message_start`,
|
|
51
|
+
* `delta.stop_reason` on `message_delta`). Never throws.
|
|
52
|
+
*/
|
|
53
|
+
export declare function llmStreamChunkAttrs(provider: LLMProvider, chunk: unknown): Record<string, string | number>;
|
|
54
|
+
export interface SseUsageScanner {
|
|
55
|
+
/** Feed decoded text (any chunk boundary). Never throws. */
|
|
56
|
+
feed(text: string): void;
|
|
57
|
+
/** Flush a trailing partial line and return the accumulated attrs. */
|
|
58
|
+
result(): Record<string, string | number>;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Incremental SSE scanner: buffers at most one partial line, parses complete
|
|
62
|
+
* `data:` lines for usage/model/id/finish_reason and drops everything else
|
|
63
|
+
* immediately. Lines over `SSE_MAX_LINE_BYTES` are skipped without parsing.
|
|
64
|
+
*/
|
|
65
|
+
export declare function createSseUsageScanner(provider: LLMProvider): SseUsageScanner;
|
|
66
|
+
/**
|
|
67
|
+
* Drain a (teed) SSE body through the scanner and resolve with the accumulated
|
|
68
|
+
* metadata attrs when the stream closes. Resolves — never rejects — on a stream
|
|
69
|
+
* error, returning whatever was scanned so far. Reads chunk-by-chunk with a
|
|
70
|
+
* streaming `TextDecoder`, so the body is never buffered.
|
|
71
|
+
*/
|
|
72
|
+
export declare function scanSseStreamForUsage(stream: ReadableStream<Uint8Array>, provider: LLMProvider): Promise<Record<string, string | number>>;
|
|
11
73
|
/** Content attrs, ONLY when captureContent is on. Size-capped per value, redactor applied. */
|
|
12
74
|
export declare function llmContentAttrs(provider: LLMProvider, reqBody: unknown, resBody: unknown, opts: {
|
|
13
75
|
redact?: (text: string) => string;
|
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
|
});
|
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;
|
|
@@ -203,18 +204,32 @@ export interface WorkersConfig {
|
|
|
203
204
|
};
|
|
204
205
|
/**
|
|
205
206
|
* LLM/gen_ai capture for outbound calls to known providers (OpenAI, Anthropic,
|
|
206
|
-
* CF AI Gateway
|
|
207
|
-
* 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" }]`
|
|
208
221
|
*
|
|
209
222
|
* Set `captureContent: true` to also capture prompt/completion text (off by
|
|
210
223
|
* default) — strongly recommended for AI-app RCA. Provide `redact` to scrub
|
|
211
224
|
* sensitive text before it leaves the Worker. `maxContentChars` caps the length
|
|
212
|
-
* of each captured value (default 8000 characters).
|
|
225
|
+
* of each captured value (default 8000 characters). Content is never captured
|
|
226
|
+
* from streamed responses.
|
|
213
227
|
*/
|
|
214
228
|
ai?: {
|
|
215
229
|
captureContent?: boolean;
|
|
216
230
|
redact?: (text: string) => string;
|
|
217
231
|
maxContentChars?: number;
|
|
232
|
+
gateways?: LLMGatewayConfig[];
|
|
218
233
|
};
|
|
219
234
|
}
|
|
220
235
|
/** Minimal public surface exposed by Cloudflare's Workers custom-spans API. */
|
|
@@ -424,3 +439,16 @@ export declare function withCloudflareSpan<T>(ctx: ExecutionContext | Cloudflare
|
|
|
424
439
|
*/
|
|
425
440
|
export declare function addEvent(name: string, attrs?: Record<string, string | number | boolean>): void;
|
|
426
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";
|
package/dist/workers.js
CHANGED
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
import { context, createContextKey, INVALID_SPAN_CONTEXT, trace, SpanKind, SpanStatusCode, } from "@opentelemetry/api";
|
|
11
11
|
import { suppressTracing, isTracingSuppressed } from "@opentelemetry/core";
|
|
12
12
|
import { ROOT_CONTEXT } from "@opentelemetry/api";
|
|
13
|
-
import {
|
|
13
|
+
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
14
14
|
import { BasicTracerProvider, SimpleSpanProcessor, } from "@opentelemetry/sdk-trace-base";
|
|
15
15
|
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
|
16
16
|
import { buildExporterConfig, DEFAULT_ENDPOINT, releaseResourceAttributes, } from "./core.js";
|
|
17
17
|
import { isSelfSpanAttrs, safeHostname } from "./self-span.js";
|
|
18
|
-
import {
|
|
18
|
+
import { llmRequestAttrs, llmResponseAttrs, llmContentAttrs, llmOperationName, resolveLLMProvider, scanSseStreamForUsage, } from "./llm-enrich.js";
|
|
19
19
|
import { instrumentEnv } from "./workers-bindings.js";
|
|
20
20
|
import { makeSampler, loadRemoteSamplingRate } from "./workers-sampler.js";
|
|
21
21
|
// `ExportResult` / `ExportResultCode` mirror `@opentelemetry/core`. We define
|
|
@@ -113,7 +113,9 @@ function readableSpanToOtlp(span) {
|
|
|
113
113
|
return {
|
|
114
114
|
traceId: ctx.traceId,
|
|
115
115
|
spanId: ctx.spanId,
|
|
116
|
-
|
|
116
|
+
// OTel SDK 2.x: `parentSpanId` became `parentSpanContext` (a full
|
|
117
|
+
// SpanContext); OTLP still wants the bare hex parent span id.
|
|
118
|
+
parentSpanId: span.parentSpanContext?.spanId ?? "",
|
|
117
119
|
name: span.name,
|
|
118
120
|
kind: toOtlpKind(span.kind),
|
|
119
121
|
startTimeUnixNano: hrTimeToUnixNano(span.startTime),
|
|
@@ -268,12 +270,203 @@ function injectTraceparent(input, init, traceparent) {
|
|
|
268
270
|
/** Max response body size to clone for gen_ai enrichment (512 KB). */
|
|
269
271
|
const LLM_RESPONSE_SIZE_CAP = 512 * 1024;
|
|
270
272
|
/**
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
|
|
273
|
+
* Context key under which `runNativeSpan` / `withSpan` publish the request's
|
|
274
|
+
* Cloudflare native tracer, so nested helpers (and the fetch wrapper) can join
|
|
275
|
+
* the platform trace tree instead of creating OTel-provider spans.
|
|
276
|
+
*/
|
|
277
|
+
const NATIVE_TRACING_CONTEXT_KEY = createContextKey("heystack.cloudflare.native-tracing");
|
|
278
|
+
/**
|
|
279
|
+
* Latest ingest host / ai config seen by `ensureFetchInstrumentation`. The
|
|
280
|
+
* wrapper is installed ONCE per isolate but `instrument()` may reach it first
|
|
281
|
+
* from native mode (no exporter → no ingest host) and later from direct mode
|
|
282
|
+
* (or vice-versa), so the wrapper reads these refs at call time instead of
|
|
283
|
+
* closing over the first caller's values.
|
|
284
|
+
*/
|
|
285
|
+
let _fetchIngestHost = "";
|
|
286
|
+
let _fetchAiCfg = {};
|
|
287
|
+
/** A promise whose settle handles are exposed. */
|
|
288
|
+
function deferred() {
|
|
289
|
+
let resolve;
|
|
290
|
+
let reject;
|
|
291
|
+
const promise = new Promise((res, rej) => {
|
|
292
|
+
resolve = res;
|
|
293
|
+
reject = rej;
|
|
294
|
+
});
|
|
295
|
+
return { promise, resolve, reject };
|
|
296
|
+
}
|
|
297
|
+
/** Parse the outbound target; null when it isn't an absolute URL. */
|
|
298
|
+
function parseTarget(input) {
|
|
299
|
+
try {
|
|
300
|
+
return new URL(outboundUrl(input));
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Request-side gen_ai attributes for a resolved LLM target: `gen_ai.system`,
|
|
308
|
+
* `gen_ai.operation.name`, `heystack.ai.gateway` (when routed through a
|
|
309
|
+
* configured gateway) and, when the request body is already a JSON string (the
|
|
310
|
+
* common LLM-SDK case), `gen_ai.request.*`. The outgoing Request body is never
|
|
311
|
+
* cloned/consumed. Returns the parsed body too so content capture (opt-in) can
|
|
312
|
+
* reuse it without a second parse.
|
|
313
|
+
*/
|
|
314
|
+
function llmRequestSide(resolved, url, init) {
|
|
315
|
+
let parsedReqBody = undefined;
|
|
316
|
+
if (typeof init?.body === "string") {
|
|
317
|
+
try {
|
|
318
|
+
parsedReqBody = JSON.parse(init.body);
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
// body not JSON — request enrichment limited to system/operation
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const attrs = {
|
|
325
|
+
...llmRequestAttrs(resolved.provider, parsedReqBody),
|
|
326
|
+
"gen_ai.operation.name": llmOperationName(url.pathname),
|
|
327
|
+
};
|
|
328
|
+
if (resolved.gateway)
|
|
329
|
+
attrs["heystack.ai.gateway"] = resolved.gateway;
|
|
330
|
+
return { attrs, parsedReqBody };
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Response-side gen_ai enrichment shared by the direct (OTel CLIENT span) and
|
|
334
|
+
* native (Cloudflare span) paths. Never throws and NEVER consumes the caller's
|
|
335
|
+
* body:
|
|
336
|
+
* - JSON (size-capped): parse a clone, set `gen_ai.response.*` + usage, and
|
|
337
|
+
* opt-in content attrs. `settled` resolves immediately.
|
|
338
|
+
* - `text/event-stream`: tee the body; the caller gets a Response wrapping
|
|
339
|
+
* one branch, the other is scanned incrementally for usage/model/id only
|
|
340
|
+
* (content deltas are discarded as they arrive). `settled` resolves when
|
|
341
|
+
* the stream closes or errors — the span must stay open until then.
|
|
342
|
+
* - anything else: passthrough, `settled` resolves immediately.
|
|
343
|
+
*/
|
|
344
|
+
async function enrichLLMResponse(span, provider, response, parsedReqBody, aiCfg) {
|
|
345
|
+
const done = { response, settled: Promise.resolve() };
|
|
346
|
+
try {
|
|
347
|
+
const ct = response.headers.get("content-type") ?? "";
|
|
348
|
+
const isStream = ct.includes("event-stream");
|
|
349
|
+
if (isStream) {
|
|
350
|
+
if (!response.body)
|
|
351
|
+
return done;
|
|
352
|
+
const [toCaller, toScanner] = response.body.tee();
|
|
353
|
+
const out = new Response(toCaller, response);
|
|
354
|
+
const settled = scanSseStreamForUsage(toScanner, provider)
|
|
355
|
+
.then((attrs) => {
|
|
356
|
+
for (const [k, v] of Object.entries(attrs))
|
|
357
|
+
span.setAttribute(k, v);
|
|
358
|
+
})
|
|
359
|
+
.catch(() => {
|
|
360
|
+
// scanner never rejects; belt-and-braces
|
|
361
|
+
});
|
|
362
|
+
return { response: out, settled };
|
|
363
|
+
}
|
|
364
|
+
const cl = parseInt(response.headers.get("content-length") ?? "0", 10);
|
|
365
|
+
const isJson = ct.includes("application/json");
|
|
366
|
+
const withinCap = isNaN(cl) || cl === 0 || cl <= LLM_RESPONSE_SIZE_CAP;
|
|
367
|
+
if (isJson && withinCap) {
|
|
368
|
+
const rb = await response.clone().json();
|
|
369
|
+
for (const [k, v] of Object.entries(llmResponseAttrs(provider, rb)))
|
|
370
|
+
span.setAttribute(k, v);
|
|
371
|
+
if (aiCfg.captureContent) {
|
|
372
|
+
for (const [k, v] of Object.entries(llmContentAttrs(provider, parsedReqBody, rb, {
|
|
373
|
+
redact: aiCfg.redact,
|
|
374
|
+
maxChars: aiCfg.maxContentChars,
|
|
375
|
+
}))) {
|
|
376
|
+
span.setAttribute(k, v);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
// parse failure — degrade to the plain span (no throw, no side effects)
|
|
383
|
+
}
|
|
384
|
+
return done;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Native-tracing path for an outbound LLM call: open a Cloudflare custom span
|
|
388
|
+
* named `<operation> <provider>` (e.g. `chat mistral`) via the request's
|
|
389
|
+
* `ctx.tracing`, set request-side `gen_ai.*`, run the ORIGINAL fetch inside it
|
|
390
|
+
* (so Cloudflare's own subrequest span nests underneath), then enrich from the
|
|
391
|
+
* response. Cloudflare's `enterSpan` ends the span when the callback settles,
|
|
392
|
+
* so for a streaming response the callback stays pending until the SSE body
|
|
393
|
+
* closes while the Response is handed back to the caller immediately through a
|
|
394
|
+
* deferred — that is what lets usage tokens land on the span without buffering
|
|
395
|
+
* or delaying the stream. No OTel provider/exporter is involved.
|
|
396
|
+
*/
|
|
397
|
+
function nativeLLMFetch(nativeTracing, resolved, url, input, init, originalFetch, aiCfg) {
|
|
398
|
+
const ready = deferred();
|
|
399
|
+
const name = `${llmOperationName(url.pathname)} ${resolved.provider}`;
|
|
400
|
+
let run;
|
|
401
|
+
let invoked = false;
|
|
402
|
+
try {
|
|
403
|
+
run = nativeTracing.enterSpan(name, async (nativeSpan) => {
|
|
404
|
+
invoked = true;
|
|
405
|
+
// Unsampled invocation: skip all enrichment work (no tee, no clone).
|
|
406
|
+
if (!nativeSpan.isTraced) {
|
|
407
|
+
try {
|
|
408
|
+
ready.resolve(await originalFetch(input, init));
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
ready.reject(error);
|
|
412
|
+
}
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const adapter = new NativeSpanAdapter(nativeSpan, true);
|
|
416
|
+
const { attrs, parsedReqBody } = llmRequestSide(resolved, url, init);
|
|
417
|
+
adapter.setAttributes({ ...attrs, "server.address": url.hostname });
|
|
418
|
+
try {
|
|
419
|
+
const response = await originalFetch(input, init);
|
|
420
|
+
adapter.setAttribute("http.response.status_code", response.status);
|
|
421
|
+
if (response.status >= 400)
|
|
422
|
+
adapter.setAttribute("error.type", String(response.status));
|
|
423
|
+
const { response: out, settled } = await enrichLLMResponse(adapter, resolved.provider, response, parsedReqBody, aiCfg);
|
|
424
|
+
ready.resolve(out);
|
|
425
|
+
// Keep the native span open until the (streamed) body has closed.
|
|
426
|
+
await settled;
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
430
|
+
adapter.recordException(err);
|
|
431
|
+
adapter.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
|
|
432
|
+
ready.reject(error);
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
// enterSpan itself threw (should not happen) — fail open to the plain fetch.
|
|
438
|
+
return originalFetch(input, init);
|
|
439
|
+
}
|
|
440
|
+
// A platform tracer that returns without running the callback must never
|
|
441
|
+
// hang the app's fetch — fail open to the plain fetch.
|
|
442
|
+
if (!invoked)
|
|
443
|
+
return originalFetch(input, init);
|
|
444
|
+
// All errors are routed to `ready`; make sure the callback promise never
|
|
445
|
+
// surfaces as an unhandled rejection.
|
|
446
|
+
Promise.resolve(run).catch(() => { });
|
|
447
|
+
return ready.promise;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Patch `globalThis.fetch` exactly once. The wrapper decides per call which
|
|
451
|
+
* tracing plane the current request is on:
|
|
452
|
+
*
|
|
453
|
+
* - **Native** (`ctx.tracing` present, published in OTel context by
|
|
454
|
+
* `runNativeSpan`): a lean LLM-only path. Targets that resolve to an LLM
|
|
455
|
+
* provider (direct host or a configured `ai.gateways` pattern) get a native
|
|
456
|
+
* `<operation> <provider>` span with `gen_ai.*` enrichment; every other
|
|
457
|
+
* fetch passes straight through UNTOUCHED (Cloudflare already records it).
|
|
458
|
+
* - **Direct** (an OTel span is active): a CLIENT child span + `traceparent`
|
|
459
|
+
* injection for every subrequest (distributed tracing), plus the same
|
|
460
|
+
* `gen_ai.*` enrichment for LLM targets.
|
|
461
|
+
*
|
|
462
|
+
* `ingestHost` is the bare ingest hostname (lower-case, no port) so the
|
|
463
|
+
* exporter's own uploads are never traced; native callers pass "" (no exporter).
|
|
464
|
+
* `aiCfg` configures gen_ai enrichment (content capture is opt-in).
|
|
275
465
|
*/
|
|
276
466
|
function ensureFetchInstrumentation(ingestHost, aiCfg = {}) {
|
|
467
|
+
if (ingestHost)
|
|
468
|
+
_fetchIngestHost = ingestHost;
|
|
469
|
+
_fetchAiCfg = aiCfg;
|
|
277
470
|
if (_fetchInstrumented)
|
|
278
471
|
return;
|
|
279
472
|
_fetchInstrumented = true;
|
|
@@ -284,16 +477,28 @@ function ensureFetchInstrumentation(ingestHost, aiCfg = {}) {
|
|
|
284
477
|
_originalFetch = originalFetch;
|
|
285
478
|
const wrapper = async (input, init) => {
|
|
286
479
|
const active = context.active();
|
|
287
|
-
// Passthrough UNCHANGED when
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
480
|
+
// Passthrough UNCHANGED when tracing is suppressed (the exporter's own POST,
|
|
481
|
+
// or anything under suppressTracing).
|
|
482
|
+
if (isTracingSuppressed(active)) {
|
|
483
|
+
return originalFetch(input, init);
|
|
484
|
+
}
|
|
485
|
+
// Native plane: LLM-only. Non-LLM subrequests are Cloudflare's to record.
|
|
486
|
+
const nativeTracing = active.getValue(NATIVE_TRACING_CONTEXT_KEY);
|
|
487
|
+
if (nativeTracing) {
|
|
488
|
+
const url = parseTarget(input);
|
|
489
|
+
const resolved = url ? resolveLLMProvider(url, _fetchAiCfg.gateways) : null;
|
|
490
|
+
if (!url || !resolved)
|
|
491
|
+
return originalFetch(input, init);
|
|
492
|
+
return nativeLLMFetch(nativeTracing, resolved, url, input, init, originalFetch, _fetchAiCfg);
|
|
493
|
+
}
|
|
494
|
+
// Direct plane: nothing to parent to (a fetch outside a traced request).
|
|
495
|
+
if (!trace.getSpan(active)) {
|
|
291
496
|
return originalFetch(input, init);
|
|
292
497
|
}
|
|
293
498
|
const target = outboundUrl(input);
|
|
294
499
|
const host = safeHostname(target);
|
|
295
500
|
// No parseable host, or the ingest host itself (self-span) → don't trace.
|
|
296
|
-
if (!host || host ===
|
|
501
|
+
if (!host || host === _fetchIngestHost) {
|
|
297
502
|
return originalFetch(input, init);
|
|
298
503
|
}
|
|
299
504
|
const method = outboundMethod(input, init);
|
|
@@ -308,60 +513,29 @@ function ensureFetchInstrumentation(ingestHost, aiCfg = {}) {
|
|
|
308
513
|
const sc = span.spanContext();
|
|
309
514
|
const traceparent = `00-${sc.traceId}-${sc.spanId}-01`;
|
|
310
515
|
const [reqInput, reqInit] = injectTraceparent(input, init, traceparent);
|
|
311
|
-
// gen_ai enrichment — request side.
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const provider = detectLLMProvider(host);
|
|
516
|
+
// gen_ai enrichment — request side (direct host or configured gateway).
|
|
517
|
+
const url = parseTarget(input);
|
|
518
|
+
const resolved = url ? resolveLLMProvider(url, _fetchAiCfg.gateways) : null;
|
|
315
519
|
let parsedReqBody = undefined;
|
|
316
|
-
if (
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
if (typeof bodyStr === "string") {
|
|
321
|
-
try {
|
|
322
|
-
parsedReqBody = JSON.parse(bodyStr);
|
|
323
|
-
for (const [k, v] of Object.entries(llmRequestAttrs(provider, parsedReqBody))) {
|
|
324
|
-
span.setAttribute(k, v);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
catch {
|
|
328
|
-
// body not valid JSON — skip request enrichment
|
|
329
|
-
}
|
|
330
|
-
}
|
|
520
|
+
if (url && resolved) {
|
|
521
|
+
const side = llmRequestSide(resolved, url, init);
|
|
522
|
+
parsedReqBody = side.parsedReqBody;
|
|
523
|
+
span.setAttributes(side.attrs);
|
|
331
524
|
}
|
|
525
|
+
// For a streamed LLM response the span must outlive this call (it ends
|
|
526
|
+
// when the SSE body closes), so `finally` must not end it in that case.
|
|
527
|
+
let deferEnd = false;
|
|
332
528
|
try {
|
|
333
529
|
const response = await originalFetch(reqInput, reqInit);
|
|
334
530
|
span.setAttribute("http.response.status_code", response.status);
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if (
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const cl = parseInt(response.headers.get("content-length") ?? "0", 10);
|
|
342
|
-
const isJson = ct.includes("application/json");
|
|
343
|
-
const isStream = ct.includes("event-stream");
|
|
344
|
-
const withinCap = isNaN(cl) || cl === 0 || cl <= LLM_RESPONSE_SIZE_CAP;
|
|
345
|
-
if (isJson && !isStream && withinCap) {
|
|
346
|
-
const rb = await response.clone().json();
|
|
347
|
-
for (const [k, v] of Object.entries(llmResponseAttrs(provider, rb))) {
|
|
348
|
-
span.setAttribute(k, v);
|
|
349
|
-
}
|
|
350
|
-
if (aiCfg.captureContent) {
|
|
351
|
-
for (const [k, v] of Object.entries(llmContentAttrs(provider, parsedReqBody, rb, {
|
|
352
|
-
redact: aiCfg.redact,
|
|
353
|
-
maxChars: aiCfg.maxContentChars,
|
|
354
|
-
}))) {
|
|
355
|
-
span.setAttribute(k, v);
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
catch {
|
|
361
|
-
// parse failure — degrade to plain CLIENT span (no throw, no side effects)
|
|
362
|
-
}
|
|
531
|
+
if (!resolved)
|
|
532
|
+
return response;
|
|
533
|
+
const { response: out, settled } = await enrichLLMResponse(span, resolved.provider, response, parsedReqBody, _fetchAiCfg);
|
|
534
|
+
if (out !== response) {
|
|
535
|
+
deferEnd = true;
|
|
536
|
+
void settled.then(() => span.end());
|
|
363
537
|
}
|
|
364
|
-
return
|
|
538
|
+
return out;
|
|
365
539
|
}
|
|
366
540
|
catch (error) {
|
|
367
541
|
span.recordException(error instanceof Error ? error : new Error(String(error)));
|
|
@@ -372,7 +546,8 @@ function ensureFetchInstrumentation(ingestHost, aiCfg = {}) {
|
|
|
372
546
|
throw error;
|
|
373
547
|
}
|
|
374
548
|
finally {
|
|
375
|
-
|
|
549
|
+
if (!deferEnd)
|
|
550
|
+
span.end();
|
|
376
551
|
}
|
|
377
552
|
};
|
|
378
553
|
_fetchWrapper = wrapper;
|
|
@@ -390,6 +565,8 @@ export function __resetFetchInstrumentation() {
|
|
|
390
565
|
_fetchInstrumented = false;
|
|
391
566
|
_originalFetch = undefined;
|
|
392
567
|
_fetchWrapper = undefined;
|
|
568
|
+
_fetchIngestHost = "";
|
|
569
|
+
_fetchAiCfg = {};
|
|
393
570
|
}
|
|
394
571
|
// ---------------------------------------------------------------------------
|
|
395
572
|
// Exporter
|
|
@@ -563,7 +740,6 @@ export class HeystackSpanExporter {
|
|
|
563
740
|
await Promise.allSettled([...this.pending]);
|
|
564
741
|
}
|
|
565
742
|
}
|
|
566
|
-
const NATIVE_TRACING_CONTEXT_KEY = createContextKey("heystack.cloudflare.native-tracing");
|
|
567
743
|
function nativeTracingFromContext() {
|
|
568
744
|
return context.active().getValue(NATIVE_TRACING_CONTEXT_KEY);
|
|
569
745
|
}
|
|
@@ -581,11 +757,17 @@ class NativeSpanAdapter {
|
|
|
581
757
|
}
|
|
582
758
|
spanContext() { return INVALID_SPAN_CONTEXT; }
|
|
583
759
|
setAttribute(key, value) {
|
|
584
|
-
|
|
585
|
-
|
|
760
|
+
try {
|
|
761
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
762
|
+
this.nativeSpan.setAttribute(key, value);
|
|
763
|
+
}
|
|
764
|
+
else if (Array.isArray(value)) {
|
|
765
|
+
this.nativeSpan.setAttribute(key, JSON.stringify(value));
|
|
766
|
+
}
|
|
586
767
|
}
|
|
587
|
-
|
|
588
|
-
|
|
768
|
+
catch {
|
|
769
|
+
// Telemetry must never surface as an application error (e.g. a platform
|
|
770
|
+
// span rejecting a value); drop the attribute instead.
|
|
589
771
|
}
|
|
590
772
|
return this;
|
|
591
773
|
}
|
|
@@ -609,9 +791,13 @@ class NativeSpanAdapter {
|
|
|
609
791
|
}
|
|
610
792
|
addLink(_link) { return this; }
|
|
611
793
|
addLinks(_links) { return this; }
|
|
794
|
+
/** True once `recordException` stamped a specific `error.type` (the exception class). */
|
|
795
|
+
errorTyped = false;
|
|
612
796
|
setStatus(status) {
|
|
613
797
|
if (status.code === SpanStatusCode.ERROR) {
|
|
614
|
-
|
|
798
|
+
// Don't clobber the more specific class name a preceding recordException set.
|
|
799
|
+
if (!this.errorTyped)
|
|
800
|
+
this.nativeSpan.setAttribute("error.type", "error");
|
|
615
801
|
if (status.message)
|
|
616
802
|
this.nativeSpan.setAttribute("error.message", status.message);
|
|
617
803
|
}
|
|
@@ -626,6 +812,7 @@ class NativeSpanAdapter {
|
|
|
626
812
|
recordException(exception, _time) {
|
|
627
813
|
const error = exception instanceof Error ? exception : new Error(String(exception));
|
|
628
814
|
this.nativeSpan.setAttribute("error.type", error.name || "Error");
|
|
815
|
+
this.errorTyped = true;
|
|
629
816
|
this.nativeSpan.setAttribute("error.message", error.message);
|
|
630
817
|
if (error.stack)
|
|
631
818
|
this.nativeSpan.setAttribute("exception.stacktrace", error.stack);
|
|
@@ -687,7 +874,7 @@ export function createTracerProvider(config) {
|
|
|
687
874
|
const provider = new BasicTracerProvider({
|
|
688
875
|
// service.name is always present; service.version/service.build are added
|
|
689
876
|
// only when the caller supplied `version`/`build` (release attribution).
|
|
690
|
-
resource:
|
|
877
|
+
resource: resourceFromAttributes({
|
|
691
878
|
[ATTR_SERVICE_NAME]: config.service,
|
|
692
879
|
...releaseResourceAttributes(config),
|
|
693
880
|
}),
|
|
@@ -938,6 +1125,11 @@ export function instrument(handler, config) {
|
|
|
938
1125
|
// `withSpan()` discovers the request's native tracer through OTel
|
|
939
1126
|
// context. Register our WinterCG-safe manager even though native mode
|
|
940
1127
|
// intentionally does not create an OTel provider/exporter.
|
|
1128
|
+
//
|
|
1129
|
+
// Install the (LLM-only under native) fetch wrapper so outbound calls
|
|
1130
|
+
// to LLM providers/gateways still get gen_ai.* enrichment — native mode
|
|
1131
|
+
// never runs `setup()`, which is where the direct plane installs it.
|
|
1132
|
+
ensureFetchInstrumentation("", config.ai ?? {});
|
|
941
1133
|
return runNativeSpan(nativeTracing, "heystack.request", {}, async (adapter) => {
|
|
942
1134
|
setNativeRequestAttributes(adapter, config, req);
|
|
943
1135
|
const response = await originalFetch(req, env, ctx);
|
|
@@ -1123,6 +1315,7 @@ export function instrument(handler, config) {
|
|
|
1123
1315
|
wrapped.queue = async (batch, env, ctx) => {
|
|
1124
1316
|
const nativeTracing = config.nativeTracing === "direct" ? undefined : getNativeTracing(ctx);
|
|
1125
1317
|
if (nativeTracing) {
|
|
1318
|
+
ensureFetchInstrumentation("", config.ai ?? {});
|
|
1126
1319
|
return runNativeSpan(nativeTracing, `heystack.queue ${batch.queue}`, {
|
|
1127
1320
|
"service.name": config.service,
|
|
1128
1321
|
"messaging.batch.message_count": batch.messages.length,
|
|
@@ -1166,6 +1359,7 @@ export function instrument(handler, config) {
|
|
|
1166
1359
|
wrapped.scheduled = async (controller, env, ctx) => {
|
|
1167
1360
|
const nativeTracing = config.nativeTracing === "direct" ? undefined : getNativeTracing(ctx);
|
|
1168
1361
|
if (nativeTracing) {
|
|
1362
|
+
ensureFetchInstrumentation("", config.ai ?? {});
|
|
1169
1363
|
return runNativeSpan(nativeTracing, `heystack.scheduled ${controller.cron}`, {
|
|
1170
1364
|
"service.name": config.service,
|
|
1171
1365
|
"faas.trigger": "timer",
|
|
@@ -1255,3 +1449,19 @@ export function addEvent(name, attrs) {
|
|
|
1255
1449
|
const span = trace.getSpan(context.active());
|
|
1256
1450
|
span?.addEvent(name, attrs);
|
|
1257
1451
|
}
|
|
1452
|
+
// ---------------------------------------------------------------------------
|
|
1453
|
+
// gen_ai helpers for manual spans (e.g. `env.AI.run`, or any LLM call the fetch
|
|
1454
|
+
// wrapper cannot see). Pure, WinterCG-safe; pair them with `withSpan`.
|
|
1455
|
+
// ---------------------------------------------------------------------------
|
|
1456
|
+
/**
|
|
1457
|
+
* `gen_ai.system` + `gen_ai.request.*` from an LLM request body (model,
|
|
1458
|
+
* max_tokens, temperature). Alias of the internal request mapper.
|
|
1459
|
+
*/
|
|
1460
|
+
export { llmRequestAttrs as genAiRequestAttrs } from "./llm-enrich.js";
|
|
1461
|
+
/**
|
|
1462
|
+
* `gen_ai.response.*` + `gen_ai.usage.*` from an LLM response object. Understands
|
|
1463
|
+
* OpenAI/Mistral (`usage.prompt_tokens`/`completion_tokens`), Anthropic
|
|
1464
|
+
* (`usage.input_tokens`/`output_tokens`) and Google (`usageMetadata`) shapes —
|
|
1465
|
+
* Workers AI results (`usage.prompt_tokens`/`completion_tokens`) included.
|
|
1466
|
+
*/
|
|
1467
|
+
export { llmResponseAttrs as genAiResponseAttrs } from "./llm-enrich.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heystack/otel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Runtime-aware OpenTelemetry tracing that exports to Heystack (Node, Next.js, Workers).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -50,13 +50,13 @@
|
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@opentelemetry/api": "^1.9.0",
|
|
53
|
-
"@opentelemetry/core": "^
|
|
54
|
-
"@opentelemetry/sdk-node": "^0.
|
|
55
|
-
"@opentelemetry/exporter-trace-otlp-http": "^0.
|
|
56
|
-
"@opentelemetry/auto-instrumentations-node": "^0.
|
|
57
|
-
"@opentelemetry/sdk-trace-base": "^
|
|
58
|
-
"@opentelemetry/resources": "^
|
|
59
|
-
"@opentelemetry/semantic-conventions": "^1.
|
|
53
|
+
"@opentelemetry/core": "^2.11.0",
|
|
54
|
+
"@opentelemetry/sdk-node": "^0.222.0",
|
|
55
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
|
|
56
|
+
"@opentelemetry/auto-instrumentations-node": "^0.80.0",
|
|
57
|
+
"@opentelemetry/sdk-trace-base": "^2.11.0",
|
|
58
|
+
"@opentelemetry/resources": "^2.11.0",
|
|
59
|
+
"@opentelemetry/semantic-conventions": "^1.43.0",
|
|
60
60
|
"rrweb": "^2.0.0-alpha.18"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|