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.
- package/README.md +1 -1
- package/dist/agent.js +6 -2
- package/dist/context/error.d.ts +15 -0
- package/dist/context/error.js +17 -0
- package/dist/context/guard.d.ts +8 -0
- package/dist/context/guard.js +41 -0
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/core/metrics.d.ts +4 -1
- package/dist/core/metrics.js +32 -2
- package/dist/core/pricing.d.ts +2 -0
- package/dist/core/pricing.js +51 -15
- package/dist/core/spans.js +27 -2
- package/dist/core/tracer.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/instrumentations/anthropic.js +8 -2
- package/dist/instrumentations/create.js +47 -3
- package/dist/instrumentations/types.d.ts +2 -0
- package/dist/types/attributes.d.ts +1 -0
- package/dist/types/attributes.js +1 -0
- package/dist/types/config.d.ts +7 -0
- package/dist/types/metrics.d.ts +3 -0
- package/dist/types/metrics.js +4 -0
- package/dist/types/spans.d.ts +4 -0
- package/package.json +1 -1
- package/templates/grafana/dashboards/agent-workflow.json +154 -31
- package/templates/grafana/dashboards/cost-breakdown.json +168 -40
- package/templates/grafana/dashboards/error-drilldown.json +155 -37
- package/templates/grafana/dashboards/finops-attribution.json +192 -43
- package/templates/grafana/dashboards/latency-analysis.json +103 -24
- package/templates/grafana/dashboards/llm-overview.json +268 -66
- package/templates/grafana/dashboards/model-comparison.json +119 -26
- 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
|
-

|
|
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
|
+
}
|
package/dist/core/metrics.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/metrics.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/pricing.d.ts
CHANGED
|
@@ -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).
|
package/dist/core/pricing.js
CHANGED
|
@@ -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": {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
"
|
|
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": {
|
|
19
|
-
|
|
20
|
-
|
|
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": {
|
|
23
|
-
|
|
24
|
-
|
|
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
|
/**
|
package/dist/core/spans.js
CHANGED
|
@@ -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];
|
package/dist/core/tracer.js
CHANGED
|
@@ -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"
|
|
51
|
-
|
|
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
|
-
|
|
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";
|
package/dist/types/attributes.js
CHANGED
|
@@ -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",
|
package/dist/types/config.d.ts
CHANGED
|
@@ -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. */
|
package/dist/types/metrics.d.ts
CHANGED
|
@@ -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: {
|
package/dist/types/metrics.js
CHANGED
|
@@ -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 = {
|
package/dist/types/spans.d.ts
CHANGED
|
@@ -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 */
|