toad-eye 2.1.1 → 2.3.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.
Files changed (53) hide show
  1. package/README.md +166 -152
  2. package/dist/agent.d.ts +19 -28
  3. package/dist/agent.js +46 -27
  4. package/dist/alerts/channels.js +24 -6
  5. package/dist/alerts/index.js +46 -2
  6. package/dist/alerts/manager.d.ts +1 -0
  7. package/dist/alerts/manager.js +36 -21
  8. package/dist/budget/tracker.d.ts +16 -6
  9. package/dist/budget/tracker.js +38 -17
  10. package/dist/budget/types.d.ts +2 -0
  11. package/dist/cli.d.ts +8 -1
  12. package/dist/cli.js +106 -54
  13. package/dist/core/metrics.d.ts +5 -0
  14. package/dist/core/metrics.js +30 -0
  15. package/dist/core/pricing.d.ts +2 -0
  16. package/dist/core/pricing.js +6 -0
  17. package/dist/core/spans.d.ts +5 -3
  18. package/dist/core/spans.js +84 -19
  19. package/dist/core/tracer.d.ts +0 -3
  20. package/dist/core/tracer.js +44 -8
  21. package/dist/drift/baseline.js +7 -1
  22. package/dist/drift/monitor.js +6 -2
  23. package/dist/drift/openai-embeddings.js +5 -1
  24. package/dist/export.js +1 -1
  25. package/dist/guard.d.ts +6 -8
  26. package/dist/guard.js +19 -21
  27. package/dist/index.d.ts +7 -2
  28. package/dist/index.js +6 -1
  29. package/dist/instrumentations/anthropic.d.ts +1 -1
  30. package/dist/instrumentations/anthropic.js +12 -20
  31. package/dist/instrumentations/create.d.ts +0 -4
  32. package/dist/instrumentations/create.js +136 -44
  33. package/dist/instrumentations/gemini.d.ts +1 -1
  34. package/dist/instrumentations/gemini.js +39 -7
  35. package/dist/instrumentations/openai.d.ts +1 -1
  36. package/dist/instrumentations/openai.js +10 -19
  37. package/dist/instrumentations/registry.d.ts +3 -1
  38. package/dist/instrumentations/registry.js +28 -5
  39. package/dist/instrumentations/types.d.ts +12 -8
  40. package/dist/types/attributes.d.ts +3 -0
  41. package/dist/types/attributes.js +3 -0
  42. package/dist/types/config.d.ts +22 -2
  43. package/dist/types/index.d.ts +3 -3
  44. package/dist/types/metrics.d.ts +3 -0
  45. package/dist/types/metrics.js +5 -0
  46. package/dist/types/providers.d.ts +6 -1
  47. package/dist/types/spans.d.ts +11 -2
  48. package/dist/vercel.d.ts +53 -0
  49. package/dist/vercel.js +106 -0
  50. package/package.json +29 -3
  51. package/templates/grafana/dashboards/agent-workflow.json +207 -0
  52. package/templates/grafana/dashboards/provider-health.json +191 -0
  53. package/templates/otel-collector.yml +25 -1
@@ -1,8 +1,10 @@
1
1
  import { createRequire } from "node:module";
2
- import { trace, diag, SpanStatusCode } from "@opentelemetry/api";
3
- import { traceLLMCall } from "../core/spans.js";
2
+ import { trace, context, diag, SpanStatusCode, } from "@opentelemetry/api";
3
+ import { traceLLMCall, processContent } from "../core/spans.js";
4
4
  import { calculateCost } from "../core/pricing.js";
5
- import { recordRequestDuration, recordRequestCost, recordTokens, recordRequest, recordError, } from "../core/metrics.js";
5
+ import { recordRequestDuration, recordRequestCost, recordTokens, recordRequest, recordError, recordTimeToFirstToken, recordResponseEmpty, recordResponseLatencyPerToken, recordBudgetExceeded, recordBudgetDowngraded, } from "../core/metrics.js";
6
+ import { getConfig, getBudgetTracker } from "../core/tracer.js";
7
+ import { ToadBudgetExceededError } from "../budget/index.js";
6
8
  import { GEN_AI_ATTRS, INSTRUMENTATION_NAME } from "../types/index.js";
7
9
  const require = createRequire(import.meta.url);
8
10
  function isModuleInstalled(moduleName) {
@@ -21,95 +23,189 @@ function loadModule(moduleName) {
21
23
  return sdk.default ?? sdk;
22
24
  }
23
25
  /**
24
- * Wrap an async iterable stream to intercept chunks.
25
- * Yields every chunk transparently to the caller while collecting them.
26
- * On stream end, calls onComplete with all accumulated chunks.
26
+ * Wrap an async iterable stream with an accumulator.
27
+ * Yields every chunk transparently. Accumulates only extracted data (no raw chunks stored).
28
+ * On stream end, calls onComplete with the accumulated result.
29
+ * onFirstChunk is called once when the first chunk arrives (for TTFT tracking).
27
30
  */
28
- async function* wrapAsyncIterable(stream, onChunk, onComplete, onError) {
29
- const chunks = [];
31
+ async function* wrapAsyncIterable(stream, accumulate, onFirstChunk, onComplete, onError) {
32
+ const acc = {
33
+ completion: "",
34
+ inputTokens: 0,
35
+ outputTokens: 0,
36
+ };
37
+ let firstChunk = true;
38
+ let completed = false;
39
+ let errored = false;
30
40
  try {
31
41
  for await (const chunk of stream) {
32
- chunks.push(chunk);
33
- onChunk(chunk);
42
+ if (firstChunk) {
43
+ onFirstChunk();
44
+ firstChunk = false;
45
+ }
46
+ accumulate(acc, chunk);
34
47
  yield chunk;
35
48
  }
36
- onComplete(chunks);
49
+ completed = true;
50
+ onComplete(acc);
37
51
  }
38
52
  catch (err) {
53
+ errored = true;
39
54
  onError(err);
40
55
  throw err;
41
56
  }
57
+ finally {
58
+ // If stream was abandoned (consumer broke out), still record what we have
59
+ if (!completed && !errored) {
60
+ onComplete(acc);
61
+ }
62
+ }
42
63
  }
43
64
  function createStreamingHandler(
44
65
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
66
  original, patch, providerName) {
46
67
  const tracer = trace.getTracer(INSTRUMENTATION_NAME);
47
68
  return async function handleStreaming(thisArg, body, rest) {
48
- const req = patch.extractRequest(body);
69
+ // Pass thisArg so extractRequest can access instance properties (e.g., Gemini model name)
70
+ const req = patch.extractRequest(body, thisArg);
49
71
  const start = performance.now();
50
- const response = await original.call(thisArg, body, ...rest);
51
- // The response should be an async iterable (Stream, MessageStream, etc.)
52
- // We wrap it to intercept chunks
53
- const span = tracer.startSpan(`gen_ai.${providerName}.${req.model}`);
72
+ // Budget check BEFORE the LLM call — mirrors traceLLMCall behavior
73
+ const budget = getBudgetTracker();
74
+ const config = getConfig();
75
+ const userId = config?.attributes?.[GEN_AI_ATTRS.USER_ID];
76
+ const estimatedCost = budget ? calculateCost(req.model, 500, 200) : 0;
77
+ let effectiveProvider = providerName;
78
+ let effectiveModel = req.model;
79
+ if (budget) {
80
+ const override = budget.checkBefore(providerName, req.model, userId, estimatedCost);
81
+ if (override) {
82
+ effectiveProvider = override.provider;
83
+ effectiveModel = override.model;
84
+ recordBudgetDowngraded(override.budget);
85
+ }
86
+ }
87
+ const span = tracer.startSpan(`gen_ai.${effectiveProvider}.${effectiveModel}`);
88
+ const ctx = trace.setSpan(context.active(), span);
89
+ const sessionId = config?.sessionExtractor?.() ?? config?.sessionId;
54
90
  span.setAttributes({
55
- [GEN_AI_ATTRS.PROVIDER]: providerName,
56
- [GEN_AI_ATTRS.REQUEST_MODEL]: req.model,
91
+ [GEN_AI_ATTRS.PROVIDER]: effectiveProvider,
92
+ [GEN_AI_ATTRS.REQUEST_MODEL]: effectiveModel,
57
93
  [GEN_AI_ATTRS.TEMPERATURE]: req.temperature ?? 1.0,
58
94
  [GEN_AI_ATTRS.OPERATION]: "chat",
95
+ ...(sessionId !== undefined && { [GEN_AI_ATTRS.SESSION_ID]: sessionId }),
59
96
  });
60
- const wrapped = wrapAsyncIterable(response, () => { }, (chunks) => {
97
+ let response;
98
+ try {
99
+ response = await original.call(thisArg, body, ...rest);
100
+ }
101
+ catch (err) {
61
102
  const duration = performance.now() - start;
62
- const res = patch.extractStreamResponse(chunks);
63
- const cost = calculateCost(req.model, res.inputTokens, res.outputTokens);
103
+ const message = err instanceof Error ? err.message : String(err);
104
+ // Release budget reservation — no cost was incurred
105
+ if (budget)
106
+ budget.releaseReservation(estimatedCost);
64
107
  span.setAttributes({
65
- [GEN_AI_ATTRS.RESPONSE_MODEL]: req.model,
66
- [GEN_AI_ATTRS.INPUT_TOKENS]: res.inputTokens,
67
- [GEN_AI_ATTRS.OUTPUT_TOKENS]: res.outputTokens,
108
+ [GEN_AI_ATTRS.STATUS]: "error",
109
+ [GEN_AI_ATTRS.ERROR]: message,
110
+ });
111
+ span.setStatus({ code: SpanStatusCode.ERROR, message });
112
+ span.end();
113
+ recordRequest(effectiveProvider, effectiveModel);
114
+ recordRequestDuration(duration, effectiveProvider, effectiveModel);
115
+ recordError(effectiveProvider, effectiveModel);
116
+ throw err;
117
+ }
118
+ // Some SDKs (Gemini) return { stream: AsyncIterable } instead of a direct AsyncIterable
119
+ const resp = response;
120
+ const hasStreamProp = resp != null &&
121
+ typeof resp === "object" &&
122
+ "stream" in resp &&
123
+ resp["stream"] != null &&
124
+ typeof resp["stream"] === "object" &&
125
+ Symbol.asyncIterator in resp["stream"];
126
+ const streamIterable = hasStreamProp
127
+ ? resp["stream"]
128
+ : response;
129
+ const wrapped = context.bind(ctx, wrapAsyncIterable(streamIterable, (acc, chunk) => patch.accumulateChunk(acc, chunk), () => {
130
+ recordTimeToFirstToken(performance.now() - start, effectiveProvider, effectiveModel);
131
+ }, (acc) => {
132
+ const duration = performance.now() - start;
133
+ const cost = calculateCost(effectiveModel, acc.inputTokens, acc.outputTokens);
134
+ const processedCompletion = processContent(acc.completion);
135
+ span.setAttributes({
136
+ ...(processedCompletion !== undefined && {
137
+ [GEN_AI_ATTRS.COMPLETION]: processedCompletion,
138
+ }),
139
+ [GEN_AI_ATTRS.RESPONSE_MODEL]: effectiveModel,
140
+ [GEN_AI_ATTRS.INPUT_TOKENS]: acc.inputTokens,
141
+ [GEN_AI_ATTRS.OUTPUT_TOKENS]: acc.outputTokens,
68
142
  [GEN_AI_ATTRS.COST]: cost,
69
143
  [GEN_AI_ATTRS.STATUS]: "success",
144
+ [GEN_AI_ATTRS.FINISH_REASONS]: ["stop"],
70
145
  });
71
146
  span.setStatus({ code: SpanStatusCode.OK });
72
147
  span.end();
73
- recordRequest(providerName, req.model);
74
- recordRequestDuration(duration, providerName, req.model);
75
- recordRequestCost(cost, providerName, req.model);
76
- recordTokens(res.inputTokens + res.outputTokens, providerName, req.model);
148
+ recordRequest(effectiveProvider, effectiveModel);
149
+ recordRequestDuration(duration, effectiveProvider, effectiveModel);
150
+ recordRequestCost(cost, effectiveProvider, effectiveModel);
151
+ recordTokens(acc.inputTokens + acc.outputTokens, effectiveProvider, effectiveModel);
152
+ // Quality metrics
153
+ if (acc.completion.trim() === "") {
154
+ recordResponseEmpty(effectiveProvider, effectiveModel);
155
+ }
156
+ if (acc.outputTokens > 0) {
157
+ recordResponseLatencyPerToken(duration / acc.outputTokens, effectiveProvider, effectiveModel);
158
+ }
159
+ // Budget recording — releases the reservation made in checkBefore
160
+ if (budget) {
161
+ const exceeded = budget.recordCost(cost, effectiveModel, userId, estimatedCost);
162
+ if (exceeded) {
163
+ recordBudgetExceeded(exceeded.budget);
164
+ console.warn(`toad-eye: ${exceeded.budget} budget exceeded — limit $${exceeded.limit}, current $${exceeded.current.toFixed(2)}`);
165
+ }
166
+ }
77
167
  }, (err) => {
78
168
  const duration = performance.now() - start;
79
169
  const message = err instanceof Error ? err.message : String(err);
170
+ // Release budget reservation on stream error
171
+ if (budget)
172
+ budget.releaseReservation(estimatedCost);
80
173
  span.setAttributes({
81
174
  [GEN_AI_ATTRS.STATUS]: "error",
82
175
  [GEN_AI_ATTRS.ERROR]: message,
83
176
  });
84
177
  span.setStatus({ code: SpanStatusCode.ERROR, message });
85
178
  span.end();
86
- recordRequest(providerName, req.model);
87
- recordRequestDuration(duration, providerName, req.model);
88
- recordError(providerName, req.model);
179
+ recordRequest(effectiveProvider, effectiveModel);
180
+ recordRequestDuration(duration, effectiveProvider, effectiveModel);
181
+ recordError(effectiveProvider, effectiveModel);
182
+ }));
183
+ // Proxy preserves the original object shape (getters, private fields, methods)
184
+ // while intercepting only the async iterator for instrumentation
185
+ return new Proxy(response, {
186
+ get(target, prop, receiver) {
187
+ if (prop === Symbol.asyncIterator) {
188
+ return () => wrapped[Symbol.asyncIterator]();
189
+ }
190
+ return Reflect.get(target, prop, receiver);
191
+ },
89
192
  });
90
- // Return an object that looks like the original stream but with our wrapper
91
- // Preserve the original object shape — SDKs may have extra methods/properties
92
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
93
- const proxy = Object.create(response);
94
- proxy[Symbol.asyncIterator] = () => wrapped[Symbol.asyncIterator]();
95
- return proxy;
96
193
  };
97
194
  }
98
195
  function createPatchedMethod(
99
196
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
197
  original, patch, providerName) {
101
- const streamHandler = patch.isStreaming && patch.extractStreamResponse
198
+ const streamHandler = patch.isStreaming && patch.accumulateChunk
102
199
  ? createStreamingHandler(original, patch, providerName)
103
200
  : null;
104
201
  return function patchedMethod(body, ...rest) {
105
202
  if (patch.shouldSkip?.(body)) {
106
203
  return original.call(this, body, ...rest);
107
204
  }
108
- // Handle streaming calls
109
205
  if (streamHandler && patch.isStreaming?.(body)) {
110
206
  return streamHandler(this, body, rest);
111
207
  }
112
- const req = patch.extractRequest(body);
208
+ const req = patch.extractRequest(body, this);
113
209
  return traceLLMCall({
114
210
  provider: providerName,
115
211
  model: req.model,
@@ -127,10 +223,6 @@ original, patch, providerName) {
127
223
  });
128
224
  };
129
225
  }
130
- /**
131
- * Factory for creating SDK instrumentations.
132
- * Eliminates boilerplate: SDK resolution, prototype patching, error handling, cleanup.
133
- */
134
226
  export function createInstrumentation(config) {
135
227
  const activePatches = [];
136
228
  return {
@@ -1 +1 @@
1
- export {};
1
+ export declare const geminiInstrumentation: import("./types.js").Instrumentation;
@@ -1,4 +1,3 @@
1
- import { register } from "./registry.js";
2
1
  import { createInstrumentation } from "./create.js";
3
2
  function extractPrompt(request) {
4
3
  if (typeof request === "string")
@@ -18,11 +17,10 @@ function extractPrompt(request) {
18
17
  const generateContent = {
19
18
  getPrototype: (sdk) => sdk?.GenerativeModel?.prototype,
20
19
  method: "generateContent",
21
- extractRequest(body) {
20
+ extractRequest(body, thisArg) {
22
21
  return {
23
22
  prompt: extractPrompt(body),
24
- // model comes from `this.model` — injected by create.ts via the patched function's `this`
25
- model: "unknown",
23
+ model: thisArg?.model ?? "unknown",
26
24
  };
27
25
  },
28
26
  extractResponse: (response) => {
@@ -41,8 +39,42 @@ const generateContent = {
41
39
  };
42
40
  },
43
41
  };
44
- register(createInstrumentation({
42
+ const generateContentStream = {
43
+ getPrototype: (sdk) => sdk?.GenerativeModel?.prototype,
44
+ method: "generateContentStream",
45
+ extractRequest(body, thisArg) {
46
+ return {
47
+ prompt: extractPrompt(body),
48
+ model: thisArg?.model ?? "unknown",
49
+ };
50
+ },
51
+ extractResponse: () => ({
52
+ completion: "",
53
+ inputTokens: 0,
54
+ outputTokens: 0,
55
+ }),
56
+ // generateContentStream always returns a stream
57
+ isStreaming: () => true,
58
+ accumulateChunk: (acc, chunk) => {
59
+ // Gemini stream chunks are GenerateContentResponse objects
60
+ const c = chunk;
61
+ try {
62
+ const text = c?.text?.();
63
+ if (text)
64
+ acc.completion += text;
65
+ }
66
+ catch {
67
+ // text() may throw if content is blocked
68
+ }
69
+ if (c?.usageMetadata) {
70
+ acc.inputTokens = c.usageMetadata.promptTokenCount ?? acc.inputTokens;
71
+ acc.outputTokens =
72
+ c.usageMetadata.candidatesTokenCount ?? acc.outputTokens;
73
+ }
74
+ },
75
+ };
76
+ export const geminiInstrumentation = createInstrumentation({
45
77
  name: "gemini",
46
78
  moduleName: "@google/generative-ai",
47
- patches: [generateContent],
48
- }));
79
+ patches: [generateContent, generateContentStream],
80
+ });
@@ -1 +1 @@
1
- export {};
1
+ export declare const openaiInstrumentation: import("./types.js").Instrumentation;
@@ -1,4 +1,3 @@
1
- import { register } from "./registry.js";
2
1
  import { createInstrumentation } from "./create.js";
3
2
  function extractMessages(messages) {
4
3
  if (!Array.isArray(messages))
@@ -46,23 +45,15 @@ const chatCompletions = {
46
45
  };
47
46
  },
48
47
  isStreaming: (body) => !!body?.stream,
49
- extractStreamResponse: (chunks) => {
50
- // OpenAI stream chunks: { choices: [{ delta: { content } }], usage?: { ... } }
51
- let completion = "";
52
- let inputTokens = 0;
53
- let outputTokens = 0;
54
- for (const chunk of chunks) {
55
- const c = chunk;
56
- const delta = c?.choices?.[0]?.delta?.content;
57
- if (delta)
58
- completion += delta;
59
- // Usage is typically in the final chunk (when stream_options.include_usage is set)
60
- if (c?.usage) {
61
- inputTokens = c.usage.prompt_tokens ?? inputTokens;
62
- outputTokens = c.usage.completion_tokens ?? outputTokens;
63
- }
48
+ accumulateChunk: (acc, chunk) => {
49
+ const c = chunk;
50
+ const delta = c?.choices?.[0]?.delta?.content;
51
+ if (delta)
52
+ acc.completion += delta;
53
+ if (c?.usage) {
54
+ acc.inputTokens = c.usage.prompt_tokens ?? acc.inputTokens;
55
+ acc.outputTokens = c.usage.completion_tokens ?? acc.outputTokens;
64
56
  }
65
- return { completion, inputTokens, outputTokens };
66
57
  },
67
58
  };
68
59
  const embeddings = {
@@ -87,8 +78,8 @@ const embeddings = {
87
78
  };
88
79
  },
89
80
  };
90
- register(createInstrumentation({
81
+ export const openaiInstrumentation = createInstrumentation({
91
82
  name: "openai",
92
83
  moduleName: "openai",
93
84
  patches: [chatCompletions, embeddings],
94
- }));
85
+ });
@@ -1,5 +1,7 @@
1
1
  import type { LLMProvider } from "../types/index.js";
2
2
  import type { Instrumentation } from "./types.js";
3
3
  export declare function register(inst: Instrumentation): void;
4
- export declare function enableAll(providers: readonly LLMProvider[]): void;
4
+ /** Skip lazy registration (used in tests that register mocks manually). */
5
+ export declare function skipAutoRegister(): void;
6
+ export declare function enableAll(providers: readonly LLMProvider[]): Promise<void>;
5
7
  export declare function disableAll(): void;
@@ -1,25 +1,48 @@
1
- import { diag } from "@opentelemetry/api";
2
1
  const instrumentations = new Map();
3
2
  const active = new Set();
4
3
  export function register(inst) {
5
4
  instrumentations.set(inst.name, inst);
6
5
  }
7
- export function enableAll(providers) {
6
+ /**
7
+ * Lazy registration — loads provider modules and registers instrumentations
8
+ * only when enableAll() is first called. This avoids pulling in create.ts,
9
+ * metrics.ts, and node:module on every `import` of toad-eye.
10
+ */
11
+ let registered = false;
12
+ /** Skip lazy registration (used in tests that register mocks manually). */
13
+ export function skipAutoRegister() {
14
+ registered = true;
15
+ }
16
+ async function ensureRegistered() {
17
+ if (registered)
18
+ return;
19
+ registered = true;
20
+ const [openai, anthropic, gemini] = await Promise.all([
21
+ import("./openai.js"),
22
+ import("./anthropic.js"),
23
+ import("./gemini.js"),
24
+ ]);
25
+ register(openai.openaiInstrumentation);
26
+ register(anthropic.anthropicInstrumentation);
27
+ register(gemini.geminiInstrumentation);
28
+ }
29
+ export async function enableAll(providers) {
30
+ await ensureRegistered();
31
+ const validProviders = Array.from(instrumentations.keys()).join(", ");
8
32
  for (const name of providers) {
9
33
  if (active.has(name))
10
34
  continue;
11
35
  const inst = instrumentations.get(name);
12
36
  if (!inst) {
13
- diag.warn(`toad-eye: unknown provider "${name}", skipping`);
37
+ console.warn(`toad-eye: unknown provider "${name}" — valid providers: ${validProviders}`);
14
38
  continue;
15
39
  }
16
40
  const patched = inst.enable();
17
41
  if (patched) {
18
42
  active.add(name);
19
- diag.debug(`toad-eye: auto-instrumented ${name}`);
20
43
  }
21
44
  else {
22
- diag.debug(`toad-eye: ${name} SDK not found, skipping`);
45
+ console.warn(`toad-eye: "${name}" SDK not found — install it to enable auto-instrumentation: npm install ${name === "gemini" ? "@google/generative-ai" : name}`);
23
46
  }
24
47
  }
25
48
  }
@@ -6,14 +6,22 @@ export interface Instrumentation {
6
6
  /** Restore original methods. */
7
7
  disable(): void;
8
8
  }
9
+ /** Accumulated stream data — only primitives, no raw SDK objects. */
10
+ export interface StreamAccumulator {
11
+ completion: string;
12
+ inputTokens: number;
13
+ outputTokens: number;
14
+ }
9
15
  /** Describes a single method to monkey-patch on an SDK prototype. */
10
16
  export interface PatchTarget {
11
17
  /** How to find the prototype from the loaded SDK module */
12
18
  getPrototype: (sdk: any) => any | undefined;
13
19
  /** Method name on the prototype to patch */
14
20
  method: string;
15
- /** Extract LLMCallInput fields from the request arguments */
16
- extractRequest: (body: unknown) => {
21
+ /** Extract LLMCallInput fields from the request arguments.
22
+ * thisArg is the SDK object instance — use it when the model name is not in the request body
23
+ * (e.g., Gemini's GenerativeModel stores it as this.model). */
24
+ extractRequest: (body: unknown, thisArg?: unknown) => {
17
25
  prompt: string;
18
26
  model: string;
19
27
  temperature?: number;
@@ -28,10 +36,6 @@ export interface PatchTarget {
28
36
  shouldSkip?: (body: unknown) => boolean;
29
37
  /** Return true if this call is a streaming request */
30
38
  isStreaming?: (body: unknown) => boolean;
31
- /** Extract final stats from accumulated stream chunks. Required when isStreaming is set. */
32
- extractStreamResponse?: (chunks: unknown[]) => {
33
- completion: string;
34
- inputTokens: number;
35
- outputTokens: number;
36
- };
39
+ /** Process one stream chunk — extract only the data we need into the accumulator. */
40
+ accumulateChunk?: (acc: StreamAccumulator, chunk: unknown) => void;
37
41
  }
@@ -19,6 +19,9 @@ export declare const GEN_AI_ATTRS: {
19
19
  readonly AGENT_STEP_NUMBER: "gen_ai.agent.step.number";
20
20
  readonly AGENT_TOOL_NAME: "gen_ai.agent.tool.name";
21
21
  readonly AGENT_STEP_CONTENT: "gen_ai.agent.step.content";
22
+ readonly AGENT_HANDOFF_TO: "gen_ai.agent.handoff.to";
23
+ readonly AGENT_HANDOFF_REASON: "gen_ai.agent.handoff.reason";
24
+ readonly AGENT_LOOP_COUNT: "gen_ai.agent.loop_count";
22
25
  readonly GUARD_MODE: "gen_ai.toad_eye.guard.mode";
23
26
  readonly GUARD_PASSED: "gen_ai.toad_eye.guard.passed";
24
27
  readonly GUARD_FAILURE_REASON: "gen_ai.toad_eye.guard.failure_reason";
@@ -21,6 +21,9 @@ export const GEN_AI_ATTRS = {
21
21
  AGENT_STEP_NUMBER: "gen_ai.agent.step.number",
22
22
  AGENT_TOOL_NAME: "gen_ai.agent.tool.name",
23
23
  AGENT_STEP_CONTENT: "gen_ai.agent.step.content",
24
+ AGENT_HANDOFF_TO: "gen_ai.agent.handoff.to",
25
+ AGENT_HANDOFF_REASON: "gen_ai.agent.handoff.reason",
26
+ AGENT_LOOP_COUNT: "gen_ai.agent.loop_count",
24
27
  // Guard (shadow mode) attributes
25
28
  GUARD_MODE: "gen_ai.toad_eye.guard.mode",
26
29
  GUARD_PASSED: "gen_ai.toad_eye.guard.passed",
@@ -1,5 +1,19 @@
1
- import type { LLMProvider } from "./providers.js";
1
+ import type { InstrumentTarget } from "./providers.js";
2
2
  import type { BudgetConfig, BudgetExceededMode, DowngradeCallback } from "../budget/types.js";
3
+ /** Sampling configuration. Tail sampling runs in OTel Collector, SDK sends all spans. */
4
+ export interface SamplingConfig {
5
+ /** SDK-side sampling rate (0.0–1.0). Default: 1.0 (send everything to Collector). */
6
+ readonly sdkRate?: number | undefined;
7
+ /** Collector-side tail sampling settings (used when generating otel-collector.yml). */
8
+ readonly collector?: {
9
+ /** Keep 100% of error traces. Default: true */
10
+ readonly keepErrors?: boolean | undefined;
11
+ /** Keep 100% of traces slower than this (ms). Default: 2000 */
12
+ readonly highLatencyMs?: number | undefined;
13
+ /** Sample this % of healthy traffic (0–100). Default: 10 */
14
+ readonly healthyRate?: number | undefined;
15
+ } | undefined;
16
+ }
3
17
  /**
4
18
  * Configuration for initObservability().
5
19
  * This is what the user passes when connecting toad-eye to their service.
@@ -20,7 +34,13 @@ export interface ToadEyeConfig {
20
34
  readonly salt?: string | undefined;
21
35
  /** Regex patterns to redact from prompt/completion text before recording. */
22
36
  readonly redactPatterns?: readonly RegExp[] | undefined;
23
- readonly instrument?: readonly LLMProvider[] | undefined;
37
+ /** Enable built-in redaction patterns for common PII: email, SSN, credit card, phone. */
38
+ readonly redactDefaults?: boolean | undefined;
39
+ /** Log what was masked to console (for debugging redaction config). No data is sent externally. */
40
+ readonly auditMasking?: boolean | undefined;
41
+ /** Configure trace sampling. Tail sampling happens in OTel Collector, not SDK. */
42
+ readonly sampling?: SamplingConfig | undefined;
43
+ readonly instrument?: readonly InstrumentTarget[] | undefined;
24
44
  /** Budget limits — daily, per-user, per-model spend caps in USD. */
25
45
  readonly budgets?: BudgetConfig | undefined;
26
46
  /** What to do when budget is exceeded: 'warn' (log), 'block' (throw), 'downgrade' (callback). */
@@ -9,9 +9,9 @@
9
9
  * - spans.ts — span-related types (LLMSpanAttributes, SpanStatus)
10
10
  */
11
11
  export declare const INSTRUMENTATION_NAME = "toad-eye";
12
- export type { LLMProvider } from "./providers.js";
13
- export type { ToadEyeConfig } from "./config.js";
12
+ export type { LLMProvider, InstrumentTarget } from "./providers.js";
13
+ export type { ToadEyeConfig, SamplingConfig } from "./config.js";
14
14
  export { GEN_AI_ATTRS, LLM_ATTRS } from "./attributes.js";
15
15
  export { GEN_AI_METRICS, LLM_METRICS } from "./metrics.js";
16
16
  export type { MetricName } from "./metrics.js";
17
- export type { SpanStatus, LLMSpanAttributes, AgentStepType, AgentStepInput, GuardMode, GuardResult, } from "./spans.js";
17
+ export type { SpanStatus, LLMSpanAttributes, AgentStepType, AgentStepInput, AgentQueryOptions, GuardMode, GuardResult, } from "./spans.js";
@@ -17,9 +17,12 @@ export declare const GEN_AI_METRICS: {
17
17
  readonly GUARD_EVALUATIONS: "gen_ai.toad_eye.guard.evaluations";
18
18
  readonly GUARD_WOULD_BLOCK: "gen_ai.toad_eye.guard.would_block";
19
19
  readonly SEMANTIC_DRIFT: "gen_ai.toad_eye.semantic_drift";
20
+ readonly TIME_TO_FIRST_TOKEN: "gen_ai.client.time_to_first_token";
20
21
  readonly BUDGET_EXCEEDED: "gen_ai.toad_eye.budget.exceeded";
21
22
  readonly BUDGET_BLOCKED: "gen_ai.toad_eye.budget.blocked";
22
23
  readonly BUDGET_DOWNGRADED: "gen_ai.toad_eye.budget.downgraded";
24
+ readonly RESPONSE_EMPTY: "gen_ai.toad_eye.response.empty";
25
+ readonly RESPONSE_LATENCY_PER_TOKEN: "gen_ai.toad_eye.response.latency_per_token";
23
26
  };
24
27
  /** @deprecated Use GEN_AI_METRICS instead. Kept for backward compatibility. */
25
28
  export declare const LLM_METRICS: {
@@ -20,10 +20,15 @@ export const GEN_AI_METRICS = {
20
20
  GUARD_WOULD_BLOCK: "gen_ai.toad_eye.guard.would_block",
21
21
  // Drift metrics
22
22
  SEMANTIC_DRIFT: "gen_ai.toad_eye.semantic_drift",
23
+ // Streaming metrics
24
+ TIME_TO_FIRST_TOKEN: "gen_ai.client.time_to_first_token",
23
25
  // Budget metrics
24
26
  BUDGET_EXCEEDED: "gen_ai.toad_eye.budget.exceeded",
25
27
  BUDGET_BLOCKED: "gen_ai.toad_eye.budget.blocked",
26
28
  BUDGET_DOWNGRADED: "gen_ai.toad_eye.budget.downgraded",
29
+ // Response quality proxy metrics
30
+ RESPONSE_EMPTY: "gen_ai.toad_eye.response.empty",
31
+ RESPONSE_LATENCY_PER_TOKEN: "gen_ai.toad_eye.response.latency_per_token",
27
32
  };
28
33
  /** @deprecated Use GEN_AI_METRICS instead. Kept for backward compatibility. */
29
34
  export const LLM_METRICS = {
@@ -2,4 +2,9 @@
2
2
  * LLM providers supported by toad-eye.
3
3
  * Values follow OTel GenAI semconv `gen_ai.provider.name`.
4
4
  */
5
- export type LLMProvider = "anthropic" | "gemini" | "openai";
5
+ export type LLMProvider = "anthropic" | "gemini" | "openai" | (string & {});
6
+ /**
7
+ * All instrumentable targets — includes LLM providers + framework SDKs.
8
+ * 'ai' = Vercel AI SDK (uses SpanProcessor, not monkey-patching).
9
+ */
10
+ export type InstrumentTarget = LLMProvider | "ai";
@@ -1,14 +1,23 @@
1
1
  import type { LLMProvider } from "./providers.js";
2
2
  /** Span status values used in trace attributes. */
3
3
  export type SpanStatus = "success" | "error";
4
- /** ReAct agent step types: think → act → observe → answer */
5
- export type AgentStepType = "think" | "act" | "observe" | "answer";
4
+ /** ReAct agent step types: think → act → observe → answer → handoff */
5
+ export type AgentStepType = "think" | "act" | "observe" | "answer" | "handoff";
6
6
  /** Input for traceAgentStep — describes a single agent step */
7
7
  export interface AgentStepInput {
8
8
  readonly type: AgentStepType;
9
9
  readonly stepNumber: number;
10
10
  readonly content?: string | undefined;
11
11
  readonly toolName?: string | undefined;
12
+ /** Target agent name for handoff steps */
13
+ readonly toAgent?: string | undefined;
14
+ /** Reason for handoff */
15
+ readonly handoffReason?: string | undefined;
16
+ }
17
+ /** Options for traceAgentQuery */
18
+ export interface AgentQueryOptions {
19
+ /** Max steps before recording a warning. Default: 25 */
20
+ readonly maxSteps?: number | undefined;
12
21
  }
13
22
  /** Guard execution mode */
14
23
  export type GuardMode = "shadow" | "enforce";