@heystack/otel 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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, Google). Metadata (model, tokens, latency, finish_reason) is
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 { Resource } from "@opentelemetry/resources";
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 { detectLLMProvider, llmRequestAttrs, llmResponseAttrs, llmContentAttrs } from "./llm-enrich.js";
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
- parentSpanId: span.parentSpanId ?? "",
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
- * Patch `globalThis.fetch` exactly once to emit a CLIENT child span + inject
272
- * `traceparent` for outbound subrequests. `ingestHost` is the bare ingest
273
- * hostname (lower-case, no port) so the exporter's own uploads are never traced.
274
- * `aiCfg` enables gen_ai semantic-convention enrichment for known LLM providers.
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 there is nothing to parent to (no active span,
288
- // e.g. a fetch outside a traced request) or tracing is suppressed (the
289
- // exporter's own POST, or anything under suppressTracing).
290
- if (isTracingSuppressed(active) || !trace.getSpan(active)) {
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 === ingestHost) {
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
- // Only when host is a known LLM provider and the body is already a string
313
- // (the common LLM SDK case). Never clone/read the outgoing Request body.
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 (provider !== null) {
317
- // Only read the request body when it's already a string (the common LLM
318
- // SDK case). Never clone/consume the outgoing Request.
319
- const bodyStr = typeof init?.body === "string" ? init.body : undefined;
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
- // gen_ai enrichment — response side.
336
- // Guard: known provider, JSON content-type (not event-stream), size cap.
337
- // Always clone — NEVER consume the original response body.
338
- if (provider !== null) {
339
- try {
340
- const ct = response.headers.get("content-type") ?? "";
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 response;
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
- span.end();
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
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
585
- this.nativeSpan.setAttribute(key, value);
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
- else if (Array.isArray(value)) {
588
- this.nativeSpan.setAttribute(key, JSON.stringify(value));
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
- this.nativeSpan.setAttribute("error.type", "error");
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: new 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.13.0",
3
+ "version": "0.15.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": "^1.30.0",
54
- "@opentelemetry/sdk-node": "^0.57.0",
55
- "@opentelemetry/exporter-trace-otlp-http": "^0.57.0",
56
- "@opentelemetry/auto-instrumentations-node": "^0.55.0",
57
- "@opentelemetry/sdk-trace-base": "^1.30.0",
58
- "@opentelemetry/resources": "^1.30.0",
59
- "@opentelemetry/semantic-conventions": "^1.30.0",
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": {