toad-eye 2.4.5 → 2.5.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 (34) hide show
  1. package/README.md +1 -1
  2. package/dist/agent.js +6 -2
  3. package/dist/context/error.d.ts +15 -0
  4. package/dist/context/error.js +17 -0
  5. package/dist/context/guard.d.ts +8 -0
  6. package/dist/context/guard.js +41 -0
  7. package/dist/context/index.d.ts +2 -0
  8. package/dist/context/index.js +2 -0
  9. package/dist/core/metrics.d.ts +4 -1
  10. package/dist/core/metrics.js +32 -2
  11. package/dist/core/pricing.d.ts +2 -0
  12. package/dist/core/pricing.js +51 -15
  13. package/dist/core/spans.js +27 -2
  14. package/dist/core/tracer.js +2 -0
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.js +1 -0
  17. package/dist/instrumentations/anthropic.js +8 -2
  18. package/dist/instrumentations/create.js +47 -3
  19. package/dist/instrumentations/types.d.ts +2 -0
  20. package/dist/types/attributes.d.ts +1 -0
  21. package/dist/types/attributes.js +1 -0
  22. package/dist/types/config.d.ts +7 -0
  23. package/dist/types/metrics.d.ts +3 -0
  24. package/dist/types/metrics.js +4 -0
  25. package/dist/types/spans.d.ts +4 -0
  26. package/package.json +1 -1
  27. package/templates/grafana/dashboards/agent-workflow.json +154 -31
  28. package/templates/grafana/dashboards/cost-breakdown.json +168 -40
  29. package/templates/grafana/dashboards/error-drilldown.json +155 -37
  30. package/templates/grafana/dashboards/finops-attribution.json +192 -43
  31. package/templates/grafana/dashboards/latency-analysis.json +103 -24
  32. package/templates/grafana/dashboards/llm-overview.json +268 -66
  33. package/templates/grafana/dashboards/model-comparison.json +119 -26
  34. package/templates/grafana/dashboards/provider-health.json +137 -31
package/README.md CHANGED
@@ -385,7 +385,7 @@ import { ToadEyeAISpanProcessor, withToadEye } from "toad-eye/vercel"; // Vercel
385
385
 
386
386
  ## Architecture
387
387
 
388
- ![toad-eye architecture](https://raw.githubusercontent.com/vola-trebla/toad-eye/main/demo/toad_eye_architecture.svg)
388
+ ![toad-eye architecture](https://raw.githubusercontent.com/vola-trebla/toad-eye/main/demo/toad_eye_architecture_v2.svg)
389
389
 
390
390
  ## What it tracks
391
391
 
package/dist/agent.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { trace, SpanStatusCode } from "@opentelemetry/api";
2
2
  import { GEN_AI_ATTRS, INSTRUMENTATION_NAME } from "./types/index.js";
3
- import { recordAgentSteps, recordAgentToolUsage } from "./core/metrics.js";
3
+ import { recordAgentSteps, recordAgentToolUsage, recordAgentToolDuration, } from "./core/metrics.js";
4
4
  import { getConfig, shouldEmitDeprecatedAttrs } from "./core/tracer.js";
5
5
  const tracer = trace.getTracer(INSTRUMENTATION_NAME);
6
6
  const DEFAULT_MAX_STEPS = 25;
@@ -58,7 +58,11 @@ function traceAgentStep(input) {
58
58
  }),
59
59
  });
60
60
  if (input.type === "act" && input.toolName !== undefined) {
61
- recordAgentToolUsage(input.toolName);
61
+ recordAgentToolUsage(input.toolName, input.toolStatus ?? "success");
62
+ if (input.toolDurationMs !== undefined) {
63
+ recordAgentToolDuration(input.toolName, input.toolDurationMs);
64
+ span.setAttribute("gen_ai.tool.duration_ms", input.toolDurationMs);
65
+ }
62
66
  }
63
67
  span.setStatus({ code: SpanStatusCode.OK });
64
68
  span.end();
@@ -0,0 +1,15 @@
1
+ /** Thrown when context guard blockAt threshold is exceeded. */
2
+ export declare class ToadContextExceededError extends Error {
3
+ readonly utilization: number;
4
+ readonly threshold: number;
5
+ readonly model: string;
6
+ readonly maxContextTokens: number;
7
+ readonly inputTokens: number;
8
+ constructor(info: {
9
+ utilization: number;
10
+ threshold: number;
11
+ model: string;
12
+ maxContextTokens: number;
13
+ inputTokens: number;
14
+ });
15
+ }
@@ -0,0 +1,17 @@
1
+ /** Thrown when context guard blockAt threshold is exceeded. */
2
+ export class ToadContextExceededError extends Error {
3
+ utilization;
4
+ threshold;
5
+ model;
6
+ maxContextTokens;
7
+ inputTokens;
8
+ constructor(info) {
9
+ super(`toad-eye: context window ${(info.utilization * 100).toFixed(0)}% full (${info.inputTokens}/${info.maxContextTokens} tokens) for model ${info.model} — blocked at ${(info.threshold * 100).toFixed(0)}%`);
10
+ this.name = "ToadContextExceededError";
11
+ this.utilization = info.utilization;
12
+ this.threshold = info.threshold;
13
+ this.model = info.model;
14
+ this.maxContextTokens = info.maxContextTokens;
15
+ this.inputTokens = info.inputTokens;
16
+ }
17
+ }
@@ -0,0 +1,8 @@
1
+ export declare function resetContextGuardState(): void;
2
+ /**
3
+ * Check context utilization before an LLM call.
4
+ * - If utilization > blockAt → throw ToadContextExceededError
5
+ * - If utilization > warnAt → console.warn (once per model per session)
6
+ * - If model unknown or no guard configured → skip silently
7
+ */
8
+ export declare function checkContextGuard(model: string, inputTokens: number): void;
@@ -0,0 +1,41 @@
1
+ import { getModelPricing } from "../core/pricing.js";
2
+ import { getConfig } from "../core/tracer.js";
3
+ import { ToadContextExceededError } from "./error.js";
4
+ let warnedModels = new Set();
5
+ export function resetContextGuardState() {
6
+ warnedModels = new Set();
7
+ }
8
+ /**
9
+ * Check context utilization before an LLM call.
10
+ * - If utilization > blockAt → throw ToadContextExceededError
11
+ * - If utilization > warnAt → console.warn (once per model per session)
12
+ * - If model unknown or no guard configured → skip silently
13
+ */
14
+ export function checkContextGuard(model, inputTokens) {
15
+ if (inputTokens <= 0)
16
+ return;
17
+ const config = getConfig();
18
+ const guard = config?.contextGuard;
19
+ if (!guard)
20
+ return;
21
+ const pricing = getModelPricing(model);
22
+ if (!pricing?.maxContextTokens)
23
+ return;
24
+ const utilization = inputTokens / pricing.maxContextTokens;
25
+ if (guard.blockAt !== undefined && utilization >= guard.blockAt) {
26
+ throw new ToadContextExceededError({
27
+ utilization,
28
+ threshold: guard.blockAt,
29
+ model,
30
+ maxContextTokens: pricing.maxContextTokens,
31
+ inputTokens,
32
+ });
33
+ }
34
+ if (guard.warnAt !== undefined && utilization >= guard.warnAt) {
35
+ const key = `${model}:${Math.floor(utilization * 10)}`;
36
+ if (!warnedModels.has(key)) {
37
+ warnedModels.add(key);
38
+ console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${model} (${inputTokens}/${pricing.maxContextTokens} tokens)`);
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,2 @@
1
+ export { ToadContextExceededError } from "./error.js";
2
+ export { checkContextGuard, resetContextGuardState } from "./guard.js";
@@ -0,0 +1,2 @@
1
+ export { ToadContextExceededError } from "./error.js";
2
+ export { checkContextGuard, resetContextGuardState } from "./guard.js";
@@ -7,7 +7,8 @@ export declare function recordTokens(count: number, provider: string, model: str
7
7
  export declare function recordRequest(provider: string, model: string, attrs?: Record<string, string>): void;
8
8
  export declare function recordError(provider: string, model: string, attrs?: Record<string, string>): void;
9
9
  export declare function recordAgentSteps(stepCount: number): void;
10
- export declare function recordAgentToolUsage(toolName: string): void;
10
+ export declare function recordAgentToolUsage(toolName: string, status?: "success" | "error"): void;
11
+ export declare function recordAgentToolDuration(toolName: string, durationMs: number): void;
11
12
  export declare function recordGuardEvaluation(ruleName: string): void;
12
13
  export declare function recordGuardWouldBlock(ruleName: string): void;
13
14
  export declare function recordSemanticDrift(drift: number, provider: string, model: string): void;
@@ -17,3 +18,5 @@ export declare function recordBudgetBlocked(budgetType: string): void;
17
18
  export declare function recordBudgetDowngraded(budgetType: string): void;
18
19
  export declare function recordResponseEmpty(provider: string, model: string, attrs?: Record<string, string>): void;
19
20
  export declare function recordResponseLatencyPerToken(msPerToken: number, provider: string, model: string, attrs?: Record<string, string>): void;
21
+ export declare function recordContextUtilization(utilization: number, provider: string, model: string): void;
22
+ export declare function recordContextBlocked(model: string): void;
@@ -7,6 +7,7 @@ let requestsTotal;
7
7
  let errorsTotal;
8
8
  let agentStepsPerQuery;
9
9
  let agentToolUsage;
10
+ let agentToolDuration;
10
11
  let guardEvaluations;
11
12
  let guardWouldBlock;
12
13
  let semanticDrift;
@@ -16,6 +17,8 @@ let budgetBlocked;
16
17
  let budgetDowngraded;
17
18
  let responseEmpty;
18
19
  let responseLatencyPerToken;
20
+ let contextUtilization;
21
+ let contextBlocked;
19
22
  let initialized = false;
20
23
  export function initMetrics() {
21
24
  if (initialized)
@@ -42,7 +45,11 @@ export function initMetrics() {
42
45
  description: "Number of agent steps per query",
43
46
  });
44
47
  agentToolUsage = meter.createCounter(GEN_AI_METRICS.AGENT_TOOL_USAGE, {
45
- description: "Agent tool invocations by tool name",
48
+ description: "Agent tool invocations by tool name and status",
49
+ });
50
+ agentToolDuration = meter.createHistogram(GEN_AI_METRICS.AGENT_TOOL_DURATION, {
51
+ description: "Agent tool execution duration in milliseconds",
52
+ unit: "ms",
46
53
  });
47
54
  guardEvaluations = meter.createCounter(GEN_AI_METRICS.GUARD_EVALUATIONS, {
48
55
  description: "Total guard evaluations (shadow + enforce)",
@@ -73,6 +80,12 @@ export function initMetrics() {
73
80
  description: "Generation latency normalized per output token (ms/token). Higher values indicate slower generation speed.",
74
81
  unit: "ms",
75
82
  });
83
+ contextUtilization = meter.createHistogram(GEN_AI_METRICS.CONTEXT_UTILIZATION, {
84
+ description: "Ratio of input tokens to model context window (0.0–1.0+). Values above 0.8 indicate risk of context overflow.",
85
+ });
86
+ contextBlocked = meter.createCounter(GEN_AI_METRICS.CONTEXT_BLOCKED, {
87
+ description: "Number of LLM calls blocked by context guard",
88
+ });
76
89
  initialized = true;
77
90
  }
78
91
  /** Reset metrics state so the next initMetrics() creates fresh instruments. Called from shutdown(). */
@@ -105,9 +118,15 @@ export function recordError(provider, model, attrs) {
105
118
  export function recordAgentSteps(stepCount) {
106
119
  agentStepsPerQuery.record(stepCount);
107
120
  }
108
- export function recordAgentToolUsage(toolName) {
121
+ export function recordAgentToolUsage(toolName, status = "success") {
109
122
  agentToolUsage.add(1, {
110
123
  [GEN_AI_ATTRS.TOOL_NAME]: toolName,
124
+ "tool.status": status,
125
+ });
126
+ }
127
+ export function recordAgentToolDuration(toolName, durationMs) {
128
+ agentToolDuration.record(durationMs, {
129
+ [GEN_AI_ATTRS.TOOL_NAME]: toolName,
111
130
  });
112
131
  }
113
132
  export function recordGuardEvaluation(ruleName) {
@@ -147,3 +166,14 @@ export function recordResponseEmpty(provider, model, attrs) {
147
166
  export function recordResponseLatencyPerToken(msPerToken, provider, model, attrs) {
148
167
  responseLatencyPerToken.record(msPerToken, baseLabels(provider, model, attrs));
149
168
  }
169
+ export function recordContextUtilization(utilization, provider, model) {
170
+ contextUtilization.record(utilization, {
171
+ [GEN_AI_ATTRS.PROVIDER]: provider,
172
+ [GEN_AI_ATTRS.REQUEST_MODEL]: model,
173
+ });
174
+ }
175
+ export function recordContextBlocked(model) {
176
+ contextBlocked.add(1, {
177
+ [GEN_AI_ATTRS.REQUEST_MODEL]: model,
178
+ });
179
+ }
@@ -6,6 +6,8 @@
6
6
  export interface ModelPricing {
7
7
  readonly inputPer1M: number;
8
8
  readonly outputPer1M: number;
9
+ /** Maximum context window size in tokens. Used to calculate context utilization ratio. */
10
+ readonly maxContextTokens?: number | undefined;
9
11
  }
10
12
  /**
11
13
  * Set custom pricing for models (overrides built-in prices).
@@ -5,23 +5,59 @@
5
5
  */
6
6
  const BUILT_IN_PRICING = {
7
7
  // OpenAI
8
- "gpt-4o": { inputPer1M: 2.5, outputPer1M: 10 },
9
- "gpt-4o-mini": { inputPer1M: 0.15, outputPer1M: 0.6 },
10
- "gpt-4-turbo": { inputPer1M: 10, outputPer1M: 30 },
11
- "gpt-4.1": { inputPer1M: 2, outputPer1M: 8 },
12
- "gpt-4.1-mini": { inputPer1M: 0.4, outputPer1M: 1.6 },
13
- "gpt-4.1-nano": { inputPer1M: 0.1, outputPer1M: 0.4 },
14
- o3: { inputPer1M: 10, outputPer1M: 40 },
15
- "o3-mini": { inputPer1M: 1.1, outputPer1M: 4.4 },
16
- "o4-mini": { inputPer1M: 1.1, outputPer1M: 4.4 },
8
+ "gpt-4o": { inputPer1M: 2.5, outputPer1M: 10, maxContextTokens: 128_000 },
9
+ "gpt-4o-mini": {
10
+ inputPer1M: 0.15,
11
+ outputPer1M: 0.6,
12
+ maxContextTokens: 128_000,
13
+ },
14
+ "gpt-4-turbo": { inputPer1M: 10, outputPer1M: 30, maxContextTokens: 128_000 },
15
+ "gpt-4.1": { inputPer1M: 2, outputPer1M: 8, maxContextTokens: 1_047_576 },
16
+ "gpt-4.1-mini": {
17
+ inputPer1M: 0.4,
18
+ outputPer1M: 1.6,
19
+ maxContextTokens: 1_047_576,
20
+ },
21
+ "gpt-4.1-nano": {
22
+ inputPer1M: 0.1,
23
+ outputPer1M: 0.4,
24
+ maxContextTokens: 1_047_576,
25
+ },
26
+ o3: { inputPer1M: 10, outputPer1M: 40, maxContextTokens: 200_000 },
27
+ "o3-mini": { inputPer1M: 1.1, outputPer1M: 4.4, maxContextTokens: 200_000 },
28
+ "o4-mini": { inputPer1M: 1.1, outputPer1M: 4.4, maxContextTokens: 200_000 },
17
29
  // Anthropic
18
- "claude-opus-4-20250514": { inputPer1M: 15, outputPer1M: 75 },
19
- "claude-sonnet-4-20250514": { inputPer1M: 3, outputPer1M: 15 },
20
- "claude-haiku-3-5-20241022": { inputPer1M: 0.8, outputPer1M: 4 },
30
+ "claude-opus-4-20250514": {
31
+ inputPer1M: 15,
32
+ outputPer1M: 75,
33
+ maxContextTokens: 200_000,
34
+ },
35
+ "claude-sonnet-4-20250514": {
36
+ inputPer1M: 3,
37
+ outputPer1M: 15,
38
+ maxContextTokens: 200_000,
39
+ },
40
+ "claude-haiku-3-5-20241022": {
41
+ inputPer1M: 0.8,
42
+ outputPer1M: 4,
43
+ maxContextTokens: 200_000,
44
+ },
21
45
  // Google Gemini
22
- "gemini-2.5-pro": { inputPer1M: 1.25, outputPer1M: 10 },
23
- "gemini-2.5-flash": { inputPer1M: 0.15, outputPer1M: 0.6 },
24
- "gemini-2.0-flash": { inputPer1M: 0.1, outputPer1M: 0.4 },
46
+ "gemini-2.5-pro": {
47
+ inputPer1M: 1.25,
48
+ outputPer1M: 10,
49
+ maxContextTokens: 1_048_576,
50
+ },
51
+ "gemini-2.5-flash": {
52
+ inputPer1M: 0.15,
53
+ outputPer1M: 0.6,
54
+ maxContextTokens: 1_048_576,
55
+ },
56
+ "gemini-2.0-flash": {
57
+ inputPer1M: 0.1,
58
+ outputPer1M: 0.4,
59
+ maxContextTokens: 1_048_576,
60
+ },
25
61
  };
26
62
  let customPricing = {};
27
63
  /**
@@ -1,9 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { trace, SpanStatusCode } from "@opentelemetry/api";
3
3
  import { GEN_AI_ATTRS, INSTRUMENTATION_NAME } from "../types/index.js";
4
- import { recordRequestDuration, recordRequestCost, recordTokens, recordRequest, recordError, recordBudgetExceeded, recordBudgetBlocked, recordBudgetDowngraded, recordResponseEmpty, recordResponseLatencyPerToken, } from "./metrics.js";
4
+ import { recordRequestDuration, recordRequestCost, recordTokens, recordRequest, recordError, recordBudgetExceeded, recordBudgetBlocked, recordBudgetDowngraded, recordResponseEmpty, recordResponseLatencyPerToken, recordContextUtilization, recordContextBlocked, } from "./metrics.js";
5
5
  import { getConfig, getBudgetTracker } from "./tracer.js";
6
- import { calculateCost } from "./pricing.js";
6
+ import { calculateCost, getModelPricing } from "./pricing.js";
7
7
  import { ToadBudgetExceededError } from "../budget/index.js";
8
8
  const tracer = trace.getTracer(INSTRUMENTATION_NAME);
9
9
  let saltWarningEmitted = false;
@@ -173,6 +173,31 @@ export async function traceLLMCall(input, fn) {
173
173
  if (output.outputTokens > 0) {
174
174
  recordResponseLatencyPerToken(duration / output.outputTokens, effectiveInput.provider, effectiveInput.model, attrs);
175
175
  }
176
+ // Context window utilization — ratio of input tokens to model's max context
177
+ const pricing = getModelPricing(effectiveInput.model);
178
+ if (pricing?.maxContextTokens && output.inputTokens > 0) {
179
+ const utilization = output.inputTokens / pricing.maxContextTokens;
180
+ span.setAttribute(GEN_AI_ATTRS.CONTEXT_UTILIZATION, utilization);
181
+ recordContextUtilization(utilization, effectiveInput.provider, effectiveInput.model);
182
+ // Context guard — post-call warning when approaching limit.
183
+ // This warns about the CURRENT call's context usage so agents
184
+ // can compress context before the NEXT call.
185
+ const guard = getConfig()?.contextGuard;
186
+ if (guard) {
187
+ if (guard.blockAt !== undefined && utilization >= guard.blockAt) {
188
+ recordContextBlocked(effectiveInput.model);
189
+ span.addEvent("gen_ai.context.limit_exceeded", {
190
+ "gen_ai.toad_eye.context_utilization": utilization,
191
+ "gen_ai.toad_eye.context.threshold": guard.blockAt,
192
+ });
193
+ console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${effectiveInput.model} — exceeds blockAt threshold ${(guard.blockAt * 100).toFixed(0)}%. Compress context before next call.`);
194
+ }
195
+ else if (guard.warnAt !== undefined &&
196
+ utilization >= guard.warnAt) {
197
+ console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${effectiveInput.model} (${output.inputTokens}/${pricing.maxContextTokens} tokens)`);
198
+ }
199
+ }
200
+ }
176
201
  // Budget recording AFTER the call — releases the cost reservation
177
202
  if (budget) {
178
203
  const userId = effectiveInput.attributes?.[GEN_AI_ATTRS.USER_ID];
@@ -7,6 +7,7 @@ import { TraceIdRatioBasedSampler, ParentBasedSampler, BatchSpanProcessor, } fro
7
7
  import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
8
8
  import { initMetrics, resetMetrics } from "./metrics.js";
9
9
  import { resetCustomPricing } from "./pricing.js";
10
+ import { resetContextGuardState } from "../context/guard.js";
10
11
  import { enableAll, disableAll } from "../instrumentations/registry.js";
11
12
  import { BudgetTracker } from "../budget/index.js";
12
13
  import { ToadEyeAISpanProcessor } from "../vercel.js";
@@ -137,4 +138,5 @@ export async function shutdown() {
137
138
  budgetTracker = null;
138
139
  resetMetrics();
139
140
  resetCustomPricing();
141
+ resetContextGuardState();
140
142
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { initObservability, shutdown, getConfig, getBudgetTracker, } from "./core/tracer.js";
2
2
  export { BudgetTracker, ToadBudgetExceededError } from "./budget/index.js";
3
+ export { ToadContextExceededError } from "./context/index.js";
3
4
  export type { BudgetConfig, BudgetExceededMode, BudgetExceededInfo, DowngradeCallback, } from "./budget/index.js";
4
5
  export { AlertManager, startAlertsFromFile, parseCondition, } from "./alerts/index.js";
5
6
  export type { AlertsConfig, AlertRule, AlertChannelConfig, FiredAlert, } from "./alerts/index.js";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { initObservability, shutdown, getConfig, getBudgetTracker, } from "./core/tracer.js";
2
2
  export { BudgetTracker, ToadBudgetExceededError } from "./budget/index.js";
3
+ export { ToadContextExceededError } from "./context/index.js";
3
4
  export { AlertManager, startAlertsFromFile, parseCondition, } from "./alerts/index.js";
4
5
  export { traceLLMCall } from "./core/spans.js";
5
6
  export { traceAgentStep, traceAgentQuery } from "./agent.js";
@@ -47,8 +47,14 @@ const messagesCreate = {
47
47
  isStreaming: (body) => !!body?.stream,
48
48
  accumulateChunk: (acc, chunk) => {
49
49
  const event = chunk;
50
- if (event.type === "content_block_delta" && event.delta?.text) {
51
- acc.completion += event.delta.text;
50
+ if (event.type === "content_block_delta") {
51
+ if (event.delta?.type === "thinking_delta" && event.delta.thinking) {
52
+ // Extended thinking — track separately from completion
53
+ acc.thinkingContent += event.delta.thinking;
54
+ }
55
+ else if (event.delta?.text) {
56
+ acc.completion += event.delta.text;
57
+ }
52
58
  }
53
59
  // Tool use: content_block_start with type "tool_use"
54
60
  if (event.type === "content_block_start" &&
@@ -1,8 +1,8 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { trace, context, diag, SpanStatusCode, } from "@opentelemetry/api";
3
3
  import { traceLLMCall, processContent } from "../core/spans.js";
4
- import { calculateCost } from "../core/pricing.js";
5
- import { recordRequestDuration, recordRequestCost, recordTokens, recordRequest, recordError, recordTimeToFirstToken, recordResponseEmpty, recordResponseLatencyPerToken, recordBudgetExceeded, recordBudgetDowngraded, } from "../core/metrics.js";
4
+ import { calculateCost, getModelPricing } from "../core/pricing.js";
5
+ import { recordRequestDuration, recordRequestCost, recordTokens, recordRequest, recordError, recordTimeToFirstToken, recordResponseEmpty, recordResponseLatencyPerToken, recordContextUtilization, recordContextBlocked, recordBudgetExceeded, recordBudgetDowngraded, } from "../core/metrics.js";
6
6
  import { getConfig, getBudgetTracker } from "../core/tracer.js";
7
7
  import { ToadBudgetExceededError } from "../budget/index.js";
8
8
  import { GEN_AI_ATTRS, INSTRUMENTATION_NAME } from "../types/index.js";
@@ -31,6 +31,7 @@ function loadModule(moduleName) {
31
31
  async function* wrapAsyncIterable(stream, accumulate, onFirstChunk, onComplete, onError) {
32
32
  const acc = {
33
33
  completion: "",
34
+ thinkingContent: "",
34
35
  inputTokens: 0,
35
36
  outputTokens: 0,
36
37
  toolCalls: [],
@@ -96,6 +97,7 @@ original, patch, providerName) {
96
97
  const span = tracer.startSpan(`chat ${effectiveModel}`);
97
98
  const ctx = trace.setSpan(context.active(), span);
98
99
  const sessionId = config?.sessionExtractor?.() ?? config?.sessionId;
100
+ let ttftMs = 0;
99
101
  span.setAttributes({
100
102
  [GEN_AI_ATTRS.PROVIDER]: effectiveProvider,
101
103
  [GEN_AI_ATTRS.REQUEST_MODEL]: effectiveModel,
@@ -136,11 +138,22 @@ original, patch, providerName) {
136
138
  ? resp["stream"]
137
139
  : response;
138
140
  const wrapped = context.bind(ctx, wrapAsyncIterable(streamIterable, (acc, chunk) => patch.accumulateChunk(acc, chunk), () => {
139
- recordTimeToFirstToken(performance.now() - start, effectiveProvider, effectiveModel);
141
+ const ttft = performance.now() - start;
142
+ ttftMs = ttft;
143
+ recordTimeToFirstToken(ttft, effectiveProvider, effectiveModel);
144
+ // Span event for per-trace TTFT debugging in Jaeger
145
+ span.addEvent("gen_ai.content.first_token", {
146
+ "gen_ai.response.time_to_first_token_ms": ttft,
147
+ });
148
+ span.setAttribute("gen_ai.response.time_to_first_token_ms", ttft);
140
149
  }, (acc) => {
141
150
  const duration = performance.now() - start;
142
151
  const cost = calculateCost(effectiveModel, acc.inputTokens, acc.outputTokens);
143
152
  const processedCompletion = processContent(acc.completion);
153
+ // Anthropic extended thinking — track separately from completion
154
+ if (acc.thinkingContent.length > 0) {
155
+ span.setAttribute("gen_ai.toad_eye.thinking.content_length", acc.thinkingContent.length);
156
+ }
144
157
  // Record tool calls from streaming chunks
145
158
  if (acc.toolCalls.length > 0) {
146
159
  span.setAttribute(GEN_AI_ATTRS.TOOL_NAME, acc.toolCalls.map((t) => t.name).join(", "));
@@ -170,6 +183,37 @@ original, patch, providerName) {
170
183
  if (acc.outputTokens > 0) {
171
184
  recordResponseLatencyPerToken(duration / acc.outputTokens, effectiveProvider, effectiveModel);
172
185
  }
186
+ // Context window utilization — ratio of input tokens to model's max context
187
+ const pricing = getModelPricing(effectiveModel);
188
+ if (pricing?.maxContextTokens && acc.inputTokens > 0) {
189
+ const utilization = acc.inputTokens / pricing.maxContextTokens;
190
+ span.setAttribute(GEN_AI_ATTRS.CONTEXT_UTILIZATION, utilization);
191
+ recordContextUtilization(utilization, effectiveProvider, effectiveModel);
192
+ // Context guard — post-stream warning
193
+ const guard = config?.contextGuard;
194
+ if (guard) {
195
+ if (guard.blockAt !== undefined && utilization >= guard.blockAt) {
196
+ recordContextBlocked(effectiveModel);
197
+ span.addEvent("gen_ai.context.limit_exceeded", {
198
+ "gen_ai.toad_eye.context_utilization": utilization,
199
+ "gen_ai.toad_eye.context.threshold": guard.blockAt,
200
+ });
201
+ console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${effectiveModel} — exceeds blockAt threshold ${(guard.blockAt * 100).toFixed(0)}%. Compress context before next call.`);
202
+ }
203
+ else if (guard.warnAt !== undefined &&
204
+ utilization >= guard.warnAt) {
205
+ console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${effectiveModel} (${acc.inputTokens}/${pricing.maxContextTokens} tokens)`);
206
+ }
207
+ }
208
+ }
209
+ // Prefill/decode latency split — TTFT = prefill, rest = decode
210
+ if (ttftMs > 0) {
211
+ const decodeMs = duration - ttftMs;
212
+ span.setAttribute("gen_ai.toad_eye.latency.decode_ms", decodeMs);
213
+ if (acc.outputTokens > 0 && decodeMs > 0) {
214
+ span.setAttribute("gen_ai.toad_eye.throughput.tokens_per_second", acc.outputTokens / (decodeMs / 1000));
215
+ }
216
+ }
173
217
  // Budget recording — releases the reservation made in checkBefore
174
218
  if (budget) {
175
219
  const exceeded = budget.recordCost(cost, effectiveModel, userId, estimatedCost);
@@ -15,6 +15,8 @@ export interface AccumulatedToolCall {
15
15
  /** Accumulated stream data — only primitives, no raw SDK objects. */
16
16
  export interface StreamAccumulator {
17
17
  completion: string;
18
+ /** Anthropic extended thinking content (tracked separately from completion). */
19
+ thinkingContent: string;
18
20
  inputTokens: number;
19
21
  outputTokens: number;
20
22
  toolCalls: AccumulatedToolCall[];
@@ -47,6 +47,7 @@ export declare const GEN_AI_ATTRS: {
47
47
  readonly COMPLETION: "gen_ai.toad_eye.completion";
48
48
  readonly COST: "gen_ai.toad_eye.cost";
49
49
  readonly STATUS: "gen_ai.toad_eye.status";
50
+ readonly CONTEXT_UTILIZATION: "gen_ai.toad_eye.context_utilization";
50
51
  readonly SESSION_ID: "session.id";
51
52
  readonly TEAM: "toad_eye.team";
52
53
  readonly USER_ID: "toad_eye.user_id";
@@ -53,6 +53,7 @@ export const GEN_AI_ATTRS = {
53
53
  COMPLETION: "gen_ai.toad_eye.completion",
54
54
  COST: "gen_ai.toad_eye.cost",
55
55
  STATUS: "gen_ai.toad_eye.status",
56
+ CONTEXT_UTILIZATION: "gen_ai.toad_eye.context_utilization",
56
57
  SESSION_ID: "session.id",
57
58
  // FinOps attribution
58
59
  TEAM: "toad_eye.team",
@@ -47,6 +47,13 @@ export interface ToadEyeConfig {
47
47
  readonly onBudgetExceeded?: BudgetExceededMode | undefined;
48
48
  /** Callback for 'downgrade' mode — receives original provider/model, returns replacement. */
49
49
  readonly downgradeCallback?: DowngradeCallback | undefined;
50
+ /** Warn or block when context window utilization exceeds thresholds. */
51
+ readonly contextGuard?: {
52
+ /** Warn when utilization exceeds this ratio (0.0–1.0). Default: none. */
53
+ readonly warnAt?: number | undefined;
54
+ /** Block (throw ToadContextExceededError) when utilization exceeds this ratio. Default: none. */
55
+ readonly blockAt?: number | undefined;
56
+ } | undefined;
50
57
  /** Static session ID — all spans will carry this value as `session.id`. */
51
58
  readonly sessionId?: string | undefined;
52
59
  /** Dynamic session ID extractor — called per traceLLMCall to resolve session ID. */
@@ -14,6 +14,7 @@ export declare const GEN_AI_METRICS: {
14
14
  readonly ERRORS: "gen_ai.client.errors";
15
15
  readonly AGENT_STEPS_PER_QUERY: "gen_ai.agent.steps_per_query";
16
16
  readonly AGENT_TOOL_USAGE: "gen_ai.agent.tool_usage";
17
+ readonly AGENT_TOOL_DURATION: "gen_ai.agent.tool_duration";
17
18
  readonly GUARD_EVALUATIONS: "gen_ai.toad_eye.guard.evaluations";
18
19
  readonly GUARD_WOULD_BLOCK: "gen_ai.toad_eye.guard.would_block";
19
20
  readonly SEMANTIC_DRIFT: "gen_ai.toad_eye.semantic_drift";
@@ -23,6 +24,8 @@ export declare const GEN_AI_METRICS: {
23
24
  readonly BUDGET_DOWNGRADED: "gen_ai.toad_eye.budget.downgraded";
24
25
  readonly RESPONSE_EMPTY: "gen_ai.toad_eye.response.empty";
25
26
  readonly RESPONSE_LATENCY_PER_TOKEN: "gen_ai.toad_eye.response.latency_per_token";
27
+ readonly CONTEXT_UTILIZATION: "gen_ai.toad_eye.context_utilization";
28
+ readonly CONTEXT_BLOCKED: "gen_ai.toad_eye.context.blocked";
26
29
  };
27
30
  /** @deprecated Use GEN_AI_METRICS instead. Kept for backward compatibility. */
28
31
  export declare const LLM_METRICS: {
@@ -15,6 +15,7 @@ export const GEN_AI_METRICS = {
15
15
  // Agent metrics
16
16
  AGENT_STEPS_PER_QUERY: "gen_ai.agent.steps_per_query",
17
17
  AGENT_TOOL_USAGE: "gen_ai.agent.tool_usage",
18
+ AGENT_TOOL_DURATION: "gen_ai.agent.tool_duration",
18
19
  // Guard metrics
19
20
  GUARD_EVALUATIONS: "gen_ai.toad_eye.guard.evaluations",
20
21
  GUARD_WOULD_BLOCK: "gen_ai.toad_eye.guard.would_block",
@@ -29,6 +30,9 @@ export const GEN_AI_METRICS = {
29
30
  // Response quality proxy metrics
30
31
  RESPONSE_EMPTY: "gen_ai.toad_eye.response.empty",
31
32
  RESPONSE_LATENCY_PER_TOKEN: "gen_ai.toad_eye.response.latency_per_token",
33
+ // Context utilization
34
+ CONTEXT_UTILIZATION: "gen_ai.toad_eye.context_utilization",
35
+ CONTEXT_BLOCKED: "gen_ai.toad_eye.context.blocked",
32
36
  };
33
37
  /** @deprecated Use GEN_AI_METRICS instead. Kept for backward compatibility. */
34
38
  export const LLM_METRICS = {
@@ -11,6 +11,10 @@ export interface AgentStepInput {
11
11
  readonly toolName?: string | undefined;
12
12
  /** Tool type per OTel GenAI spec: function, extension, retrieval, builtin */
13
13
  readonly toolType?: "function" | "extension" | "retrieval" | "builtin" | undefined;
14
+ /** Duration of tool execution in milliseconds (for act steps). */
15
+ readonly toolDurationMs?: number | undefined;
16
+ /** Whether the tool execution succeeded or failed (for act steps). */
17
+ readonly toolStatus?: "success" | "error" | undefined;
14
18
  /** Target agent name for handoff steps */
15
19
  readonly toAgent?: string | undefined;
16
20
  /** Reason for handoff */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toad-eye",
3
- "version": "2.4.5",
3
+ "version": "2.5.0",
4
4
  "description": "Auto-instrumentation, cost tracking, budget guards, and observability for LLM services",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",