@heystack/otel 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -20
- package/dist/llm-enrich.d.ts +63 -1
- package/dist/llm-enrich.js +292 -19
- package/dist/node.js +2 -2
- package/dist/workers-agents.d.ts +49 -0
- package/dist/workers-agents.js +110 -0
- package/dist/workers.d.ts +61 -3
- package/dist/workers.js +442 -63
- package/package.json +12 -8
package/dist/workers.js
CHANGED
|
@@ -7,15 +7,15 @@
|
|
|
7
7
|
// `@opentelemetry/resources` and the platform `fetch` — all WinterCG-safe — and
|
|
8
8
|
// ships its own OTLP/JSON-over-fetch span exporter so it runs on Workers/Edge
|
|
9
9
|
// where the Node SDK cannot.
|
|
10
|
-
import { context, trace, SpanKind, SpanStatusCode, } from "@opentelemetry/api";
|
|
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,6 +740,125 @@ export class HeystackSpanExporter {
|
|
|
563
740
|
await Promise.allSettled([...this.pending]);
|
|
564
741
|
}
|
|
565
742
|
}
|
|
743
|
+
function nativeTracingFromContext() {
|
|
744
|
+
return context.active().getValue(NATIVE_TRACING_CONTEXT_KEY);
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Adapts Cloudflare's deliberately-small Span API to the OTel API shape used by
|
|
748
|
+
* `withSpan`. Unsupported OTel operations degrade to attributes instead of
|
|
749
|
+
* creating a second exporter/provider.
|
|
750
|
+
*/
|
|
751
|
+
class NativeSpanAdapter {
|
|
752
|
+
nativeSpan;
|
|
753
|
+
autoEnd;
|
|
754
|
+
constructor(nativeSpan, autoEnd) {
|
|
755
|
+
this.nativeSpan = nativeSpan;
|
|
756
|
+
this.autoEnd = autoEnd;
|
|
757
|
+
}
|
|
758
|
+
spanContext() { return INVALID_SPAN_CONTEXT; }
|
|
759
|
+
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
|
+
}
|
|
767
|
+
}
|
|
768
|
+
catch {
|
|
769
|
+
// Telemetry must never surface as an application error (e.g. a platform
|
|
770
|
+
// span rejecting a value); drop the attribute instead.
|
|
771
|
+
}
|
|
772
|
+
return this;
|
|
773
|
+
}
|
|
774
|
+
setAttributes(attributes) {
|
|
775
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
776
|
+
if (value !== undefined)
|
|
777
|
+
this.setAttribute(key, value);
|
|
778
|
+
}
|
|
779
|
+
return this;
|
|
780
|
+
}
|
|
781
|
+
addEvent(name, attributesOrStartTime) {
|
|
782
|
+
const prefix = `event.${name}`;
|
|
783
|
+
this.nativeSpan.setAttribute(prefix, true);
|
|
784
|
+
if (attributesOrStartTime && !Array.isArray(attributesOrStartTime) && typeof attributesOrStartTime === "object") {
|
|
785
|
+
for (const [key, value] of Object.entries(attributesOrStartTime)) {
|
|
786
|
+
if (value !== undefined)
|
|
787
|
+
this.setAttribute(`${prefix}.${key}`, value);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return this;
|
|
791
|
+
}
|
|
792
|
+
addLink(_link) { return this; }
|
|
793
|
+
addLinks(_links) { return this; }
|
|
794
|
+
/** True once `recordException` stamped a specific `error.type` (the exception class). */
|
|
795
|
+
errorTyped = false;
|
|
796
|
+
setStatus(status) {
|
|
797
|
+
if (status.code === SpanStatusCode.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");
|
|
801
|
+
if (status.message)
|
|
802
|
+
this.nativeSpan.setAttribute("error.message", status.message);
|
|
803
|
+
}
|
|
804
|
+
return this;
|
|
805
|
+
}
|
|
806
|
+
updateName(_name) { return this; }
|
|
807
|
+
end(_endTime) {
|
|
808
|
+
if (!this.autoEnd)
|
|
809
|
+
this.nativeSpan.end();
|
|
810
|
+
}
|
|
811
|
+
isRecording() { return this.nativeSpan.isTraced; }
|
|
812
|
+
recordException(exception, _time) {
|
|
813
|
+
const error = exception instanceof Error ? exception : new Error(String(exception));
|
|
814
|
+
this.nativeSpan.setAttribute("error.type", error.name || "Error");
|
|
815
|
+
this.errorTyped = true;
|
|
816
|
+
this.nativeSpan.setAttribute("error.message", error.message);
|
|
817
|
+
if (error.stack)
|
|
818
|
+
this.nativeSpan.setAttribute("exception.stacktrace", error.stack);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
function getNativeTracing(ctx) {
|
|
822
|
+
const candidate = ctx.tracing;
|
|
823
|
+
return candidate && typeof candidate.enterSpan === "function" ? candidate : undefined;
|
|
824
|
+
}
|
|
825
|
+
function setNativeRequestAttributes(span, config, req) {
|
|
826
|
+
const url = new URL(req.url);
|
|
827
|
+
span.setAttribute("service.name", config.service);
|
|
828
|
+
span.setAttribute("http.route", url.pathname);
|
|
829
|
+
if (config.version)
|
|
830
|
+
span.setAttribute("service.version", config.version);
|
|
831
|
+
if (config.build)
|
|
832
|
+
span.setAttribute("service.build", config.build);
|
|
833
|
+
const userInfo = config.getUser?.(req);
|
|
834
|
+
if (userInfo?.id)
|
|
835
|
+
span.setAttribute("enduser.id", userInfo.id);
|
|
836
|
+
if (userInfo?.session)
|
|
837
|
+
span.setAttribute("session.id", userInfo.session);
|
|
838
|
+
const requestId = userInfo?.requestId ?? req.headers.get("cf-ray") ?? undefined;
|
|
839
|
+
if (requestId)
|
|
840
|
+
span.setAttribute("http.request.id", requestId);
|
|
841
|
+
}
|
|
842
|
+
async function runNativeSpan(nativeTracing, name, attrs, fn) {
|
|
843
|
+
ensureContextManager();
|
|
844
|
+
return nativeTracing.enterSpan(name, async (nativeSpan) => {
|
|
845
|
+
const adapter = new NativeSpanAdapter(nativeSpan, true);
|
|
846
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
847
|
+
if (value !== undefined)
|
|
848
|
+
adapter.setAttribute(key, value);
|
|
849
|
+
}
|
|
850
|
+
const nativeContext = trace.setSpan(context.active().setValue(NATIVE_TRACING_CONTEXT_KEY, nativeTracing), adapter);
|
|
851
|
+
try {
|
|
852
|
+
return await context.with(nativeContext, () => fn(adapter));
|
|
853
|
+
}
|
|
854
|
+
catch (error) {
|
|
855
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
856
|
+
adapter.recordException(err);
|
|
857
|
+
adapter.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
|
|
858
|
+
throw error;
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
}
|
|
566
862
|
/**
|
|
567
863
|
* Build a `BasicTracerProvider` wired to a `HeystackSpanExporter` via a
|
|
568
864
|
* `SimpleSpanProcessor`. Exposed for advanced users who want to add manual
|
|
@@ -578,7 +874,7 @@ export function createTracerProvider(config) {
|
|
|
578
874
|
const provider = new BasicTracerProvider({
|
|
579
875
|
// service.name is always present; service.version/service.build are added
|
|
580
876
|
// only when the caller supplied `version`/`build` (release attribution).
|
|
581
|
-
resource:
|
|
877
|
+
resource: resourceFromAttributes({
|
|
582
878
|
[ATTR_SERVICE_NAME]: config.service,
|
|
583
879
|
...releaseResourceAttributes(config),
|
|
584
880
|
}),
|
|
@@ -824,6 +1120,25 @@ export function instrument(handler, config) {
|
|
|
824
1120
|
const originalFetch = handler.fetch?.bind(handler);
|
|
825
1121
|
if (originalFetch) {
|
|
826
1122
|
wrapped.fetch = async (req, env, ctx) => {
|
|
1123
|
+
const nativeTracing = config.nativeTracing === "direct" ? undefined : getNativeTracing(ctx);
|
|
1124
|
+
if (nativeTracing) {
|
|
1125
|
+
// `withSpan()` discovers the request's native tracer through OTel
|
|
1126
|
+
// context. Register our WinterCG-safe manager even though native mode
|
|
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 ?? {});
|
|
1133
|
+
return runNativeSpan(nativeTracing, "heystack.request", {}, async (adapter) => {
|
|
1134
|
+
setNativeRequestAttributes(adapter, config, req);
|
|
1135
|
+
const response = await originalFetch(req, env, ctx);
|
|
1136
|
+
adapter.setAttribute("http.response.status_code", response.status);
|
|
1137
|
+
if (response.status >= 500)
|
|
1138
|
+
adapter.setAttribute("error.type", "http_error");
|
|
1139
|
+
return response;
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
827
1142
|
const s = setup(env);
|
|
828
1143
|
if (!s)
|
|
829
1144
|
return originalFetch(req, env, ctx);
|
|
@@ -998,6 +1313,18 @@ export function instrument(handler, config) {
|
|
|
998
1313
|
const originalQueue = handler.queue?.bind(handler);
|
|
999
1314
|
if (originalQueue) {
|
|
1000
1315
|
wrapped.queue = async (batch, env, ctx) => {
|
|
1316
|
+
const nativeTracing = config.nativeTracing === "direct" ? undefined : getNativeTracing(ctx);
|
|
1317
|
+
if (nativeTracing) {
|
|
1318
|
+
ensureFetchInstrumentation("", config.ai ?? {});
|
|
1319
|
+
return runNativeSpan(nativeTracing, `heystack.queue ${batch.queue}`, {
|
|
1320
|
+
"service.name": config.service,
|
|
1321
|
+
"messaging.batch.message_count": batch.messages.length,
|
|
1322
|
+
"messaging.destination.name": batch.queue,
|
|
1323
|
+
"messaging.operation.type": "process",
|
|
1324
|
+
"service.version": config.version,
|
|
1325
|
+
"service.build": config.build,
|
|
1326
|
+
}, () => originalQueue(batch, env, ctx));
|
|
1327
|
+
}
|
|
1001
1328
|
const s = setup(env);
|
|
1002
1329
|
if (!s)
|
|
1003
1330
|
return originalQueue(batch, env, ctx);
|
|
@@ -1030,6 +1357,17 @@ export function instrument(handler, config) {
|
|
|
1030
1357
|
const originalScheduled = handler.scheduled?.bind(handler);
|
|
1031
1358
|
if (originalScheduled) {
|
|
1032
1359
|
wrapped.scheduled = async (controller, env, ctx) => {
|
|
1360
|
+
const nativeTracing = config.nativeTracing === "direct" ? undefined : getNativeTracing(ctx);
|
|
1361
|
+
if (nativeTracing) {
|
|
1362
|
+
ensureFetchInstrumentation("", config.ai ?? {});
|
|
1363
|
+
return runNativeSpan(nativeTracing, `heystack.scheduled ${controller.cron}`, {
|
|
1364
|
+
"service.name": config.service,
|
|
1365
|
+
"faas.trigger": "timer",
|
|
1366
|
+
"cloudflare.scheduled.cron": controller.cron,
|
|
1367
|
+
"service.version": config.version,
|
|
1368
|
+
"service.build": config.build,
|
|
1369
|
+
}, () => originalScheduled(controller, env, ctx));
|
|
1370
|
+
}
|
|
1033
1371
|
const s = setup(env);
|
|
1034
1372
|
if (!s)
|
|
1035
1373
|
return originalScheduled(controller, env, ctx);
|
|
@@ -1063,6 +1401,23 @@ export function instrument(handler, config) {
|
|
|
1063
1401
|
export async function withSpan(name, attrsOrFn, maybeFn) {
|
|
1064
1402
|
const attrs = typeof attrsOrFn === "function" ? undefined : attrsOrFn;
|
|
1065
1403
|
const fn = (typeof attrsOrFn === "function" ? attrsOrFn : maybeFn);
|
|
1404
|
+
const nativeTracing = nativeTracingFromContext();
|
|
1405
|
+
if (nativeTracing) {
|
|
1406
|
+
return nativeTracing.enterSpan(name, async (nativeSpan) => {
|
|
1407
|
+
const adapter = new NativeSpanAdapter(nativeSpan, true);
|
|
1408
|
+
if (attrs)
|
|
1409
|
+
adapter.setAttributes(attrs);
|
|
1410
|
+
const nativeContext = trace.setSpan(context.active().setValue(NATIVE_TRACING_CONTEXT_KEY, nativeTracing), adapter);
|
|
1411
|
+
try {
|
|
1412
|
+
return await context.with(nativeContext, () => fn(adapter));
|
|
1413
|
+
}
|
|
1414
|
+
catch (err) {
|
|
1415
|
+
adapter.recordException(err instanceof Error ? err : new Error(String(err)));
|
|
1416
|
+
adapter.setStatus({ code: SpanStatusCode.ERROR, message: err instanceof Error ? err.message : String(err) });
|
|
1417
|
+
throw err;
|
|
1418
|
+
}
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1066
1421
|
const tracer = trace.getTracer("heystack");
|
|
1067
1422
|
const span = tracer.startSpan(name, attrs ? { attributes: attrs } : undefined);
|
|
1068
1423
|
const ctx = trace.setSpan(context.active(), span);
|
|
@@ -1078,6 +1433,14 @@ export async function withSpan(name, attrsOrFn, maybeFn) {
|
|
|
1078
1433
|
span.end();
|
|
1079
1434
|
}
|
|
1080
1435
|
}
|
|
1436
|
+
export async function withCloudflareSpan(ctx, name, attrsOrFn, maybeFn) {
|
|
1437
|
+
const attrs = typeof attrsOrFn === "function" ? {} : attrsOrFn;
|
|
1438
|
+
const fn = (typeof attrsOrFn === "function" ? attrsOrFn : maybeFn);
|
|
1439
|
+
const tracing = "enterSpan" in ctx ? ctx : getNativeTracing(ctx);
|
|
1440
|
+
if (tracing)
|
|
1441
|
+
return runNativeSpan(tracing, name, attrs, fn);
|
|
1442
|
+
return withSpan(name, attrs, fn);
|
|
1443
|
+
}
|
|
1081
1444
|
/**
|
|
1082
1445
|
* Add a named event (with optional attributes) to the currently-active span.
|
|
1083
1446
|
* No-op when no span is active.
|
|
@@ -1086,3 +1449,19 @@ export function addEvent(name, attrs) {
|
|
|
1086
1449
|
const span = trace.getSpan(context.active());
|
|
1087
1450
|
span?.addEvent(name, attrs);
|
|
1088
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",
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
"types": "./dist/workers.d.ts",
|
|
24
24
|
"import": "./dist/workers.js"
|
|
25
25
|
},
|
|
26
|
+
"./workers/agents": {
|
|
27
|
+
"types": "./dist/workers-agents.d.ts",
|
|
28
|
+
"import": "./dist/workers-agents.js"
|
|
29
|
+
},
|
|
26
30
|
"./web": {
|
|
27
31
|
"types": "./dist/web.d.ts",
|
|
28
32
|
"import": "./dist/web.js"
|
|
@@ -46,13 +50,13 @@
|
|
|
46
50
|
},
|
|
47
51
|
"dependencies": {
|
|
48
52
|
"@opentelemetry/api": "^1.9.0",
|
|
49
|
-
"@opentelemetry/core": "^
|
|
50
|
-
"@opentelemetry/sdk-node": "^0.
|
|
51
|
-
"@opentelemetry/exporter-trace-otlp-http": "^0.
|
|
52
|
-
"@opentelemetry/auto-instrumentations-node": "^0.
|
|
53
|
-
"@opentelemetry/sdk-trace-base": "^
|
|
54
|
-
"@opentelemetry/resources": "^
|
|
55
|
-
"@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",
|
|
56
60
|
"rrweb": "^2.0.0-alpha.18"
|
|
57
61
|
},
|
|
58
62
|
"devDependencies": {
|