toad-eye 2.8.0 → 2.9.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.
@@ -0,0 +1,30 @@
1
+ /**
2
+ * SpanProcessor that calls user's onSpanEnd callback with structured span data.
3
+ *
4
+ * Extracts LLM/MCP-relevant attributes from ReadableSpan into a clean
5
+ * SpanEndData object — user never touches OTel SDK internals.
6
+ */
7
+ import type { SpanProcessor, ReadableSpan } from "@opentelemetry/sdk-trace-base";
8
+ /** Structured span data passed to the onSpanEnd callback. */
9
+ export interface SpanEndData {
10
+ readonly traceId: string;
11
+ readonly spanId: string;
12
+ readonly parentSpanId: string | undefined;
13
+ readonly name: string;
14
+ readonly kind: "client" | "server" | "internal" | "producer" | "consumer";
15
+ readonly status: "ok" | "error" | "unset";
16
+ readonly durationMs: number;
17
+ readonly startTime: Date;
18
+ readonly endTime: Date;
19
+ readonly attributes: Readonly<Record<string, string | number | boolean>>;
20
+ readonly error: string | undefined;
21
+ }
22
+ export type OnSpanEndCallback = (data: SpanEndData) => void | Promise<void>;
23
+ export declare class ToadEyeSpanEndProcessor implements SpanProcessor {
24
+ private readonly callback;
25
+ constructor(callback: OnSpanEndCallback);
26
+ onStart(): void;
27
+ onEnd(span: ReadableSpan): void;
28
+ forceFlush(): Promise<void>;
29
+ shutdown(): Promise<void>;
30
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * SpanProcessor that calls user's onSpanEnd callback with structured span data.
3
+ *
4
+ * Extracts LLM/MCP-relevant attributes from ReadableSpan into a clean
5
+ * SpanEndData object — user never touches OTel SDK internals.
6
+ */
7
+ import { SpanKind, SpanStatusCode } from "@opentelemetry/api";
8
+ const SPAN_KIND_MAP = {
9
+ [SpanKind.CLIENT]: "client",
10
+ [SpanKind.SERVER]: "server",
11
+ [SpanKind.INTERNAL]: "internal",
12
+ [SpanKind.PRODUCER]: "producer",
13
+ [SpanKind.CONSUMER]: "consumer",
14
+ };
15
+ function hrtimeToDate(hrtime) {
16
+ return new Date(hrtime[0] * 1000 + hrtime[1] / 1_000_000);
17
+ }
18
+ function hrtimeDurationMs(start, end) {
19
+ return (end[0] - start[0]) * 1000 + (end[1] - start[1]) / 1_000_000;
20
+ }
21
+ function flattenAttributes(attrs) {
22
+ const result = {};
23
+ for (const [key, value] of Object.entries(attrs)) {
24
+ if (typeof value === "string" ||
25
+ typeof value === "number" ||
26
+ typeof value === "boolean") {
27
+ result[key] = value;
28
+ }
29
+ }
30
+ return result;
31
+ }
32
+ function toSpanEndData(span) {
33
+ const ctx = span.spanContext();
34
+ return {
35
+ traceId: ctx.traceId,
36
+ spanId: ctx.spanId,
37
+ parentSpanId: span.parentSpanId || undefined,
38
+ name: span.name,
39
+ kind: SPAN_KIND_MAP[span.kind] ?? "internal",
40
+ status: span.status.code === SpanStatusCode.ERROR
41
+ ? "error"
42
+ : span.status.code === SpanStatusCode.OK
43
+ ? "ok"
44
+ : "unset",
45
+ durationMs: hrtimeDurationMs(span.startTime, span.endTime),
46
+ startTime: hrtimeToDate(span.startTime),
47
+ endTime: hrtimeToDate(span.endTime),
48
+ attributes: flattenAttributes(span.attributes),
49
+ error: span.status.message || undefined,
50
+ };
51
+ }
52
+ export class ToadEyeSpanEndProcessor {
53
+ callback;
54
+ constructor(callback) {
55
+ this.callback = callback;
56
+ }
57
+ onStart() { }
58
+ onEnd(span) {
59
+ try {
60
+ const result = this.callback(toSpanEndData(span));
61
+ // If callback returns a promise, don't block — fire and forget
62
+ if (result instanceof Promise) {
63
+ result.catch((err) => {
64
+ console.warn(`toad-eye: onSpanEnd callback failed: ${err instanceof Error ? err.message : err}`);
65
+ });
66
+ }
67
+ }
68
+ catch (err) {
69
+ console.warn(`toad-eye: onSpanEnd callback failed: ${err instanceof Error ? err.message : err}`);
70
+ }
71
+ }
72
+ forceFlush() {
73
+ return Promise.resolve();
74
+ }
75
+ shutdown() {
76
+ return Promise.resolve();
77
+ }
78
+ }
@@ -13,6 +13,7 @@ import { enableMcpClientInstrumentation, disableMcpClientInstrumentation, } from
13
13
  import { resetMcpMetrics } from "../mcp/metrics.js";
14
14
  import { BudgetTracker } from "../budget/index.js";
15
15
  import { ToadEyeAISpanProcessor } from "../vercel.js";
16
+ import { ToadEyeSpanEndProcessor } from "./span-end-processor.js";
16
17
  const DEFAULT_ENDPOINT = "http://localhost:4318";
17
18
  const DEFAULT_CLOUD_ENDPOINT = "https://cloud.toad-eye.dev";
18
19
  let sdk = null;
@@ -81,11 +82,20 @@ export function initObservability(config) {
81
82
  exporter: metricExporter,
82
83
  exportIntervalMillis: isCloudMode ? 10_000 : 5_000,
83
84
  });
84
- // When custom SpanProcessors are needed (e.g., Vercel AI SDK),
85
+ // When custom SpanProcessors are needed (e.g., Vercel AI SDK, onSpanEnd),
85
86
  // we must also include a BatchSpanProcessor for trace export —
86
87
  // NodeSDK won't create one automatically when spanProcessors is provided.
87
- const spanProcessors = config.instrument?.includes("ai")
88
- ? [new BatchSpanProcessor(traceExporter), new ToadEyeAISpanProcessor()]
88
+ const needsCustomProcessors = config.instrument?.includes("ai") || config.onSpanEnd !== undefined;
89
+ const spanProcessors = needsCustomProcessors
90
+ ? [
91
+ new BatchSpanProcessor(traceExporter),
92
+ ...(config.instrument?.includes("ai")
93
+ ? [new ToadEyeAISpanProcessor()]
94
+ : []),
95
+ ...(config.onSpanEnd !== undefined
96
+ ? [new ToadEyeSpanEndProcessor(config.onSpanEnd)]
97
+ : []),
98
+ ]
89
99
  : [];
90
100
  // SDK-side head sampling (default: 1.0 = send everything to Collector)
91
101
  const sdkRate = config.sampling?.sdkRate ?? 1.0;
package/dist/index.d.ts CHANGED
@@ -20,3 +20,4 @@ export { LLM_METRICS } from "./types/index.js";
20
20
  /** @deprecated Use GEN_AI_ATTRS instead */
21
21
  export { LLM_ATTRS } from "./types/index.js";
22
22
  export { ToadEyeAISpanProcessor, withToadEye } from "./vercel.js";
23
+ export type { SpanEndData, OnSpanEndCallback, } from "./core/span-end-processor.js";
@@ -1,5 +1,6 @@
1
1
  import type { InstrumentTarget } from "./providers.js";
2
2
  import type { BudgetConfig, BudgetExceededMode, DowngradeCallback } from "../budget/types.js";
3
+ import type { OnSpanEndCallback } from "../core/span-end-processor.js";
3
4
  /** Sampling configuration. Tail sampling runs in OTel Collector, SDK sends all spans. */
4
5
  export interface SamplingConfig {
5
6
  /** SDK-side sampling rate (0.0–1.0). Default: 1.0 (send everything to Collector). */
@@ -60,4 +61,6 @@ export interface ToadEyeConfig {
60
61
  readonly sessionId?: string | undefined;
61
62
  /** Dynamic session ID extractor — called per traceLLMCall to resolve session ID. */
62
63
  readonly sessionExtractor?: (() => string | undefined) | undefined;
64
+ /** Callback invoked when any span ends. Receives structured span data for eval pipelines. */
65
+ readonly onSpanEnd?: OnSpanEndCallback | undefined;
63
66
  }
@@ -11,6 +11,7 @@
11
11
  export declare const INSTRUMENTATION_NAME = "toad-eye";
12
12
  export type { LLMProvider, InstrumentTarget } from "./providers.js";
13
13
  export type { ToadEyeConfig, SamplingConfig } from "./config.js";
14
+ export type { SpanEndData, OnSpanEndCallback, } from "../core/span-end-processor.js";
14
15
  export { GEN_AI_ATTRS, LLM_ATTRS } from "./attributes.js";
15
16
  export { GEN_AI_METRICS, LLM_METRICS } from "./metrics.js";
16
17
  export type { MetricName } from "./metrics.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toad-eye",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "description": "Observability for MCP servers and LLM applications — traces, metrics, dashboards. One-line instrumentation for OpenAI, Anthropic, Gemini, and MCP. Self-hosted with Grafana + Prometheus + Jaeger.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",