toad-eye 2.11.2 → 2.12.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 +13 -1
- package/dist/alerts/grafana.js +1 -1
- package/dist/cli.js +5 -3
- package/dist/core/lifecycle.d.ts +54 -0
- package/dist/core/lifecycle.js +110 -0
- package/dist/core/spans.js +25 -84
- package/dist/instrumentations/create.js +49 -76
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -74,6 +74,18 @@ Open [localhost:3100](http://localhost:3100) (Grafana, admin/admin). 11 dashboar
|
|
|
74
74
|
|
|
75
75
|
<!-- TODO: add Grafana dashboard screenshot here -->
|
|
76
76
|
|
|
77
|
+
## Why toad-eye
|
|
78
|
+
|
|
79
|
+
| Feature | toad-eye | Sentry MCP | Grafana Cloud (OpenLIT) |
|
|
80
|
+
| ---------------------------- | -------- | ---------- | ----------------------- |
|
|
81
|
+
| TypeScript-native | ✅ | ✅ | ❌ (Python) |
|
|
82
|
+
| Self-hosted | ✅ | ❌ | ❌ |
|
|
83
|
+
| MCP E2E tracing | ✅ | ❌ | ❌ |
|
|
84
|
+
| MCP-specific metrics | ✅ | Partial | Partial |
|
|
85
|
+
| Privacy controls | ✅ | ❌ | ❌ |
|
|
86
|
+
| Tool hallucination detection | ✅ | ❌ | ❌ |
|
|
87
|
+
| Budget guards | ✅ | ❌ | ❌ |
|
|
88
|
+
|
|
77
89
|
## What You Get
|
|
78
90
|
|
|
79
91
|
**Instrumentation:** auto-instrument OpenAI, Anthropic, Gemini, Vercel AI SDK, and MCP servers — regular and streaming calls, with full cost tracking.
|
|
@@ -169,4 +181,4 @@ Full metrics and span attribute reference: [COMPATIBILITY.md](https://github.com
|
|
|
169
181
|
|
|
170
182
|
## Tech Stack
|
|
171
183
|
|
|
172
|
-
TypeScript · OpenTelemetry SDK 2.x · Hono · Docker Compose (Prometheus, Jaeger, Grafana, OTel Collector) · Vitest (
|
|
184
|
+
TypeScript · OpenTelemetry SDK 2.x · Hono · Docker Compose (Prometheus, Jaeger, Grafana, OTel Collector) · Vitest (306+ tests)
|
package/dist/alerts/grafana.js
CHANGED
|
@@ -16,7 +16,7 @@ export async function postGrafanaAnnotation(grafanaUrl, alert, apiKey) {
|
|
|
16
16
|
body: JSON.stringify({
|
|
17
17
|
time: Date.now(),
|
|
18
18
|
tags: ["toad-eye", "alert", alert.name],
|
|
19
|
-
text: `🚨 ${alert.name}: ${alert.value.toFixed(4)}
|
|
19
|
+
text: `🚨 ${alert.name}: ${alert.value.toFixed(4)} (threshold: ${alert.threshold})`,
|
|
20
20
|
}),
|
|
21
21
|
});
|
|
22
22
|
if (!res.ok) {
|
package/dist/cli.js
CHANGED
|
@@ -263,7 +263,9 @@ async function demo() {
|
|
|
263
263
|
}
|
|
264
264
|
};
|
|
265
265
|
const run = () => {
|
|
266
|
-
tick()
|
|
266
|
+
tick()
|
|
267
|
+
.catch((err) => console.error("Demo error:", err))
|
|
268
|
+
.then(() => setTimeout(run, DEMO_TICK_INTERVAL_MS));
|
|
267
269
|
};
|
|
268
270
|
run();
|
|
269
271
|
}
|
|
@@ -342,10 +344,10 @@ function runCli() {
|
|
|
342
344
|
status();
|
|
343
345
|
break;
|
|
344
346
|
case "demo":
|
|
345
|
-
demo();
|
|
347
|
+
void demo();
|
|
346
348
|
break;
|
|
347
349
|
case "export-trace":
|
|
348
|
-
exportTraceCommand();
|
|
350
|
+
void exportTraceCommand();
|
|
349
351
|
break;
|
|
350
352
|
case "help":
|
|
351
353
|
case "--help":
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared LLM call orchestration — budget, metrics, context guard.
|
|
3
|
+
*
|
|
4
|
+
* Both traceLLMCall() (manual) and createStreamingHandler() (auto)
|
|
5
|
+
* need the same pre/post-call logic. This module provides it as
|
|
6
|
+
* composable functions to eliminate duplication.
|
|
7
|
+
*
|
|
8
|
+
* @internal Not exported from the public API.
|
|
9
|
+
*/
|
|
10
|
+
import type { Span } from "@opentelemetry/api";
|
|
11
|
+
import type { LLMProvider } from "../types/index.js";
|
|
12
|
+
import type { BudgetTracker } from "../budget/index.js";
|
|
13
|
+
export interface BudgetPreCheckResult {
|
|
14
|
+
readonly effectiveProvider: LLMProvider;
|
|
15
|
+
readonly effectiveModel: string;
|
|
16
|
+
readonly estimatedCost: number;
|
|
17
|
+
readonly budget: BudgetTracker | null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Run budget check before the LLM call.
|
|
21
|
+
* May downgrade provider/model if budget is in downgrade mode.
|
|
22
|
+
* Throws ToadBudgetExceededError if budget is in block mode.
|
|
23
|
+
*/
|
|
24
|
+
export declare function performBudgetPreCheck(provider: LLMProvider, model: string, userId: string | undefined): BudgetPreCheckResult;
|
|
25
|
+
export interface SuccessMetricsInput {
|
|
26
|
+
readonly duration: number;
|
|
27
|
+
readonly provider: string;
|
|
28
|
+
readonly model: string;
|
|
29
|
+
readonly cost: number;
|
|
30
|
+
readonly inputTokens: number;
|
|
31
|
+
readonly outputTokens: number;
|
|
32
|
+
readonly completion: string;
|
|
33
|
+
readonly attrs?: Readonly<Record<string, string>> | undefined;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Record all success-path metrics: request, duration, cost, tokens,
|
|
37
|
+
* empty response detection, latency per token.
|
|
38
|
+
*/
|
|
39
|
+
export declare function recordSuccessMetrics(input: SuccessMetricsInput): void;
|
|
40
|
+
/**
|
|
41
|
+
* Evaluate context window utilization and trigger warnings/blocks.
|
|
42
|
+
* Must be called BEFORE span.end().
|
|
43
|
+
*/
|
|
44
|
+
export declare function evaluateContextGuard(span: Span, model: string, provider: string, inputTokens: number): void;
|
|
45
|
+
/**
|
|
46
|
+
* Record actual cost after the LLM call. Releases the reservation
|
|
47
|
+
* made in performBudgetPreCheck. Warns if budget exceeded.
|
|
48
|
+
*/
|
|
49
|
+
export declare function recordBudgetPostCheck(budget: BudgetTracker | null, cost: number, model: string, userId: string | undefined, estimatedCost: number): void;
|
|
50
|
+
/**
|
|
51
|
+
* Record error-path metrics. Distinguishes budget block errors from
|
|
52
|
+
* generic errors to avoid inflating error rate.
|
|
53
|
+
*/
|
|
54
|
+
export declare function handleErrorMetrics(error: unknown, duration: number, provider: string, model: string, budget: BudgetTracker | null, estimatedCost: number, attrs?: Readonly<Record<string, string>>): void;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared LLM call orchestration — budget, metrics, context guard.
|
|
3
|
+
*
|
|
4
|
+
* Both traceLLMCall() (manual) and createStreamingHandler() (auto)
|
|
5
|
+
* need the same pre/post-call logic. This module provides it as
|
|
6
|
+
* composable functions to eliminate duplication.
|
|
7
|
+
*
|
|
8
|
+
* @internal Not exported from the public API.
|
|
9
|
+
*/
|
|
10
|
+
import { GEN_AI_ATTRS } from "../types/index.js";
|
|
11
|
+
import { calculateCost, getModelPricing } from "./pricing.js";
|
|
12
|
+
import { recordRequest, recordRequestDuration, recordRequestCost, recordTokens, recordResponseEmpty, recordResponseLatencyPerToken, recordContextUtilization, recordContextBlocked, recordBudgetExceeded, recordBudgetDowngraded, recordBudgetBlocked, recordError, } from "./metrics.js";
|
|
13
|
+
import { getConfig, getBudgetTracker } from "./tracer.js";
|
|
14
|
+
import { ToadBudgetExceededError } from "../budget/index.js";
|
|
15
|
+
/**
|
|
16
|
+
* Run budget check before the LLM call.
|
|
17
|
+
* May downgrade provider/model if budget is in downgrade mode.
|
|
18
|
+
* Throws ToadBudgetExceededError if budget is in block mode.
|
|
19
|
+
*/
|
|
20
|
+
export function performBudgetPreCheck(provider, model, userId) {
|
|
21
|
+
const budget = getBudgetTracker();
|
|
22
|
+
const estimatedCost = budget ? calculateCost(model, 500, 200) : 0;
|
|
23
|
+
let effectiveProvider = provider;
|
|
24
|
+
let effectiveModel = model;
|
|
25
|
+
if (budget) {
|
|
26
|
+
const override = budget.checkBefore(provider, model, userId, estimatedCost);
|
|
27
|
+
if (override) {
|
|
28
|
+
effectiveProvider = override.provider;
|
|
29
|
+
effectiveModel = override.model;
|
|
30
|
+
recordBudgetDowngraded(override.budget);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { effectiveProvider, effectiveModel, estimatedCost, budget };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Record all success-path metrics: request, duration, cost, tokens,
|
|
37
|
+
* empty response detection, latency per token.
|
|
38
|
+
*/
|
|
39
|
+
export function recordSuccessMetrics(input) {
|
|
40
|
+
const { duration, provider, model, cost, inputTokens, outputTokens, completion, attrs, } = input;
|
|
41
|
+
recordRequest(provider, model, attrs);
|
|
42
|
+
recordRequestDuration(duration, provider, model, attrs);
|
|
43
|
+
recordRequestCost(cost, provider, model, attrs);
|
|
44
|
+
recordTokens(inputTokens + outputTokens, provider, model, attrs);
|
|
45
|
+
if (completion.trim() === "") {
|
|
46
|
+
recordResponseEmpty(provider, model, attrs);
|
|
47
|
+
}
|
|
48
|
+
if (outputTokens > 0) {
|
|
49
|
+
recordResponseLatencyPerToken(duration / outputTokens, provider, model, attrs);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// ── Context guard ─────────────────────────────────────────────────
|
|
53
|
+
/**
|
|
54
|
+
* Evaluate context window utilization and trigger warnings/blocks.
|
|
55
|
+
* Must be called BEFORE span.end().
|
|
56
|
+
*/
|
|
57
|
+
export function evaluateContextGuard(span, model, provider, inputTokens) {
|
|
58
|
+
const pricing = getModelPricing(model);
|
|
59
|
+
if (!pricing?.maxContextTokens || inputTokens <= 0)
|
|
60
|
+
return;
|
|
61
|
+
const utilization = inputTokens / pricing.maxContextTokens;
|
|
62
|
+
span.setAttribute(GEN_AI_ATTRS.CONTEXT_UTILIZATION, utilization);
|
|
63
|
+
recordContextUtilization(utilization, provider, model);
|
|
64
|
+
const guard = getConfig()?.contextGuard;
|
|
65
|
+
if (!guard)
|
|
66
|
+
return;
|
|
67
|
+
if (guard.alertAt !== undefined && utilization >= guard.alertAt) {
|
|
68
|
+
recordContextBlocked(model);
|
|
69
|
+
span.addEvent("gen_ai.context.limit_exceeded", {
|
|
70
|
+
"gen_ai.toad_eye.context_utilization": utilization,
|
|
71
|
+
"gen_ai.toad_eye.context.threshold": guard.alertAt,
|
|
72
|
+
});
|
|
73
|
+
console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${model} — exceeds alertAt threshold ${(guard.alertAt * 100).toFixed(0)}%. Compress context before next call.`);
|
|
74
|
+
}
|
|
75
|
+
else if (guard.warnAt !== undefined && utilization >= guard.warnAt) {
|
|
76
|
+
console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${model} (${inputTokens}/${pricing.maxContextTokens} tokens)`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// ── Budget post-check ─────────────────────────────────────────────
|
|
80
|
+
/**
|
|
81
|
+
* Record actual cost after the LLM call. Releases the reservation
|
|
82
|
+
* made in performBudgetPreCheck. Warns if budget exceeded.
|
|
83
|
+
*/
|
|
84
|
+
export function recordBudgetPostCheck(budget, cost, model, userId, estimatedCost) {
|
|
85
|
+
if (!budget)
|
|
86
|
+
return;
|
|
87
|
+
const exceeded = budget.recordCost(cost, model, userId, estimatedCost);
|
|
88
|
+
if (exceeded) {
|
|
89
|
+
recordBudgetExceeded(exceeded.budget);
|
|
90
|
+
console.warn(`toad-eye: ${exceeded.budget} budget exceeded — limit $${exceeded.limit}, current $${exceeded.current.toFixed(2)}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// ── Error metrics ─────────────────────────────────────────────────
|
|
94
|
+
/**
|
|
95
|
+
* Record error-path metrics. Distinguishes budget block errors from
|
|
96
|
+
* generic errors to avoid inflating error rate.
|
|
97
|
+
*/
|
|
98
|
+
export function handleErrorMetrics(error, duration, provider, model, budget, estimatedCost, attrs) {
|
|
99
|
+
recordRequest(provider, model, attrs);
|
|
100
|
+
recordRequestDuration(duration, provider, model, attrs);
|
|
101
|
+
if (budget) {
|
|
102
|
+
budget.releaseReservation(estimatedCost);
|
|
103
|
+
}
|
|
104
|
+
if (error instanceof ToadBudgetExceededError) {
|
|
105
|
+
recordBudgetBlocked(error.budget);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
recordError(provider, model, attrs);
|
|
109
|
+
}
|
|
110
|
+
}
|
package/dist/core/spans.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
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 {
|
|
5
|
-
import { getConfig
|
|
6
|
-
import { calculateCost
|
|
7
|
-
import {
|
|
4
|
+
import { recordThinkingRatio } from "./metrics.js";
|
|
5
|
+
import { getConfig } from "./tracer.js";
|
|
6
|
+
import { calculateCost } from "./pricing.js";
|
|
7
|
+
import { performBudgetPreCheck, recordSuccessMetrics, evaluateContextGuard, recordBudgetPostCheck, handleErrorMetrics, } from "./lifecycle.js";
|
|
8
8
|
const tracer = trace.getTracer(INSTRUMENTATION_NAME);
|
|
9
9
|
let saltWarningEmitted = false;
|
|
10
10
|
// Track the last config ref so saltWarningEmitted resets across re-inits without circular imports
|
|
@@ -109,10 +109,6 @@ function setBaseAttributes(span, input) {
|
|
|
109
109
|
...attrs,
|
|
110
110
|
});
|
|
111
111
|
}
|
|
112
|
-
function recordBaseMetrics(duration, provider, model, attrs) {
|
|
113
|
-
recordRequest(provider, model, attrs);
|
|
114
|
-
recordRequestDuration(duration, provider, model, attrs);
|
|
115
|
-
}
|
|
116
112
|
function setSuccessAttributes(span, input, output, resolvedCost) {
|
|
117
113
|
const completion = processContent(output.completion);
|
|
118
114
|
span.setAttributes({
|
|
@@ -164,25 +160,15 @@ export async function traceLLMCall(input, fn) {
|
|
|
164
160
|
if (!getConfig()) {
|
|
165
161
|
console.warn("toad-eye: traceLLMCall called before initObservability() — no telemetry will be recorded.");
|
|
166
162
|
}
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const userId = input.attributes?.[GEN_AI_ATTRS.USER_ID];
|
|
175
|
-
const override = budget.checkBefore(input.provider, input.model, userId, estimatedCost);
|
|
176
|
-
if (override) {
|
|
177
|
-
// Downgrade mode — use modified provider/model
|
|
178
|
-
effectiveInput = {
|
|
179
|
-
...input,
|
|
180
|
-
provider: override.provider,
|
|
181
|
-
model: override.model,
|
|
182
|
-
};
|
|
183
|
-
recordBudgetDowngraded(override.budget);
|
|
163
|
+
const userId = input.attributes?.[GEN_AI_ATTRS.USER_ID];
|
|
164
|
+
const { effectiveProvider, effectiveModel, estimatedCost, budget } = performBudgetPreCheck(input.provider, input.model, userId);
|
|
165
|
+
const effectiveInput = effectiveProvider !== input.provider || effectiveModel !== input.model
|
|
166
|
+
? {
|
|
167
|
+
...input,
|
|
168
|
+
provider: effectiveProvider,
|
|
169
|
+
model: effectiveModel,
|
|
184
170
|
}
|
|
185
|
-
|
|
171
|
+
: input;
|
|
186
172
|
const op = effectiveInput.operationName ?? "chat";
|
|
187
173
|
return tracer.startActiveSpan(`${op} ${effectiveInput.model}`, async (span) => {
|
|
188
174
|
const start = performance.now();
|
|
@@ -194,70 +180,25 @@ export async function traceLLMCall(input, fn) {
|
|
|
194
180
|
const resolvedCost = output.cost ??
|
|
195
181
|
calculateCost(effectiveInput.model, output.inputTokens, output.outputTokens);
|
|
196
182
|
setSuccessAttributes(span, effectiveInput, output, resolvedCost);
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (pricing?.maxContextTokens && output.inputTokens > 0) {
|
|
210
|
-
const utilization = output.inputTokens / pricing.maxContextTokens;
|
|
211
|
-
span.setAttribute(GEN_AI_ATTRS.CONTEXT_UTILIZATION, utilization);
|
|
212
|
-
recordContextUtilization(utilization, effectiveInput.provider, effectiveInput.model);
|
|
213
|
-
// Context guard — post-call warning when approaching limit.
|
|
214
|
-
// This warns about the CURRENT call's context usage so agents
|
|
215
|
-
// can compress context before the NEXT call.
|
|
216
|
-
const guard = getConfig()?.contextGuard;
|
|
217
|
-
if (guard) {
|
|
218
|
-
if (guard.alertAt !== undefined && utilization >= guard.alertAt) {
|
|
219
|
-
recordContextBlocked(effectiveInput.model);
|
|
220
|
-
span.addEvent("gen_ai.context.limit_exceeded", {
|
|
221
|
-
"gen_ai.toad_eye.context_utilization": utilization,
|
|
222
|
-
"gen_ai.toad_eye.context.threshold": guard.alertAt,
|
|
223
|
-
});
|
|
224
|
-
console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${effectiveInput.model} — exceeds alertAt threshold ${(guard.alertAt * 100).toFixed(0)}%. Compress context before next call.`);
|
|
225
|
-
}
|
|
226
|
-
else if (guard.warnAt !== undefined &&
|
|
227
|
-
utilization >= guard.warnAt) {
|
|
228
|
-
console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${effectiveInput.model} (${output.inputTokens}/${pricing.maxContextTokens} tokens)`);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
// Budget recording AFTER the call — releases the cost reservation
|
|
233
|
-
if (budget) {
|
|
234
|
-
const userId = effectiveInput.attributes?.[GEN_AI_ATTRS.USER_ID];
|
|
235
|
-
const exceeded = budget.recordCost(resolvedCost, effectiveInput.model, userId, estimatedCost);
|
|
236
|
-
if (exceeded) {
|
|
237
|
-
recordBudgetExceeded(exceeded.budget);
|
|
238
|
-
console.warn(`toad-eye: ${exceeded.budget} budget exceeded — limit $${exceeded.limit}, current $${exceeded.current.toFixed(2)}`);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
183
|
+
recordSuccessMetrics({
|
|
184
|
+
duration,
|
|
185
|
+
provider: effectiveInput.provider,
|
|
186
|
+
model: effectiveInput.model,
|
|
187
|
+
cost: resolvedCost,
|
|
188
|
+
inputTokens: output.inputTokens,
|
|
189
|
+
outputTokens: output.outputTokens,
|
|
190
|
+
completion: output.completion,
|
|
191
|
+
attrs,
|
|
192
|
+
});
|
|
193
|
+
evaluateContextGuard(span, effectiveInput.model, effectiveInput.provider, output.inputTokens);
|
|
194
|
+
recordBudgetPostCheck(budget, resolvedCost, effectiveInput.model, effectiveInput.attributes?.[GEN_AI_ATTRS.USER_ID], estimatedCost);
|
|
241
195
|
return output;
|
|
242
196
|
}
|
|
243
197
|
catch (error) {
|
|
244
198
|
const duration = performance.now() - start;
|
|
245
199
|
const message = error instanceof Error ? error.message : String(error);
|
|
246
|
-
const attrs = resolveAttributes(effectiveInput);
|
|
247
200
|
setErrorAttributes(span, message);
|
|
248
|
-
|
|
249
|
-
// Release cost reservation on any error (no cost was incurred)
|
|
250
|
-
if (budget) {
|
|
251
|
-
budget.releaseReservation(estimatedCost);
|
|
252
|
-
}
|
|
253
|
-
if (error instanceof ToadBudgetExceededError) {
|
|
254
|
-
// Budget blocks are intentional guardrails, not errors — record
|
|
255
|
-
// only the budget-specific metric to avoid inflating error rate
|
|
256
|
-
recordBudgetBlocked(error.budget);
|
|
257
|
-
}
|
|
258
|
-
else {
|
|
259
|
-
recordError(effectiveInput.provider, effectiveInput.model, attrs);
|
|
260
|
-
}
|
|
201
|
+
handleErrorMetrics(error, duration, effectiveInput.provider, effectiveInput.model, budget, estimatedCost, resolveAttributes(effectiveInput));
|
|
261
202
|
throw error;
|
|
262
203
|
}
|
|
263
204
|
finally {
|
|
@@ -1,9 +1,10 @@
|
|
|
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
|
|
5
|
-
import {
|
|
6
|
-
import { getConfig
|
|
4
|
+
import { calculateCost } from "../core/pricing.js";
|
|
5
|
+
import { recordTimeToFirstToken, recordError } from "../core/metrics.js";
|
|
6
|
+
import { getConfig } from "../core/tracer.js";
|
|
7
|
+
import { performBudgetPreCheck, recordSuccessMetrics, evaluateContextGuard, recordBudgetPostCheck, handleErrorMetrics, } from "../core/lifecycle.js";
|
|
7
8
|
import { GEN_AI_ATTRS, INSTRUMENTATION_NAME } from "../types/index.js";
|
|
8
9
|
const require = createRequire(import.meta.url);
|
|
9
10
|
function isModuleInstalled(moduleName) {
|
|
@@ -75,35 +76,27 @@ original, patch, providerName) {
|
|
|
75
76
|
b["stream_options"] = { include_usage: true };
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
|
-
// Pass thisArg so extractRequest can access instance properties (e.g., Gemini model name)
|
|
79
79
|
const req = patch.extractRequest(body, thisArg);
|
|
80
80
|
const start = performance.now();
|
|
81
|
-
// Budget check BEFORE the LLM call — mirrors traceLLMCall behavior
|
|
82
|
-
const budget = getBudgetTracker();
|
|
83
81
|
const config = getConfig();
|
|
84
82
|
const userId = config?.attributes?.[GEN_AI_ATTRS.USER_ID];
|
|
85
|
-
const estimatedCost
|
|
86
|
-
let effectiveProvider = providerName;
|
|
87
|
-
let effectiveModel = req.model;
|
|
88
|
-
if (budget) {
|
|
89
|
-
const override = budget.checkBefore(providerName, req.model, userId, estimatedCost);
|
|
90
|
-
if (override) {
|
|
91
|
-
effectiveProvider = override.provider;
|
|
92
|
-
effectiveModel = override.model;
|
|
93
|
-
recordBudgetDowngraded(override.budget);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
83
|
+
const { effectiveProvider, effectiveModel, estimatedCost, budget } = performBudgetPreCheck(providerName, req.model, userId);
|
|
96
84
|
const op = patch.operationName ?? "chat";
|
|
97
85
|
const span = tracer.startSpan(`${op} ${effectiveModel}`);
|
|
98
86
|
const ctx = trace.setSpan(context.active(), span);
|
|
99
87
|
const sessionId = config?.sessionExtractor?.() ?? config?.sessionId;
|
|
100
88
|
let ttftMs = 0;
|
|
89
|
+
const processedPrompt = processContent(req.prompt);
|
|
101
90
|
span.setAttributes({
|
|
102
91
|
[GEN_AI_ATTRS.PROVIDER]: effectiveProvider,
|
|
103
92
|
[GEN_AI_ATTRS.REQUEST_MODEL]: effectiveModel,
|
|
104
93
|
[GEN_AI_ATTRS.TEMPERATURE]: req.temperature ?? 1.0,
|
|
105
94
|
[GEN_AI_ATTRS.OPERATION]: op,
|
|
106
95
|
...(sessionId !== undefined && { [GEN_AI_ATTRS.SESSION_ID]: sessionId }),
|
|
96
|
+
...(processedPrompt !== undefined && {
|
|
97
|
+
[GEN_AI_ATTRS.PROMPT]: processedPrompt,
|
|
98
|
+
}),
|
|
99
|
+
...config?.attributes,
|
|
107
100
|
});
|
|
108
101
|
let response;
|
|
109
102
|
try {
|
|
@@ -112,18 +105,20 @@ original, patch, providerName) {
|
|
|
112
105
|
catch (err) {
|
|
113
106
|
const duration = performance.now() - start;
|
|
114
107
|
const message = err instanceof Error ? err.message : String(err);
|
|
115
|
-
|
|
116
|
-
if (budget)
|
|
117
|
-
budget.releaseReservation(estimatedCost);
|
|
108
|
+
const sanitized = processContent(message);
|
|
118
109
|
span.setAttributes({
|
|
110
|
+
[GEN_AI_ATTRS.INPUT_TOKENS]: 0,
|
|
111
|
+
[GEN_AI_ATTRS.OUTPUT_TOKENS]: 0,
|
|
112
|
+
[GEN_AI_ATTRS.COST]: 0,
|
|
119
113
|
[GEN_AI_ATTRS.STATUS]: "error",
|
|
120
|
-
[GEN_AI_ATTRS.ERROR]:
|
|
114
|
+
...(sanitized !== undefined && { [GEN_AI_ATTRS.ERROR]: sanitized }),
|
|
115
|
+
});
|
|
116
|
+
span.setStatus({
|
|
117
|
+
code: SpanStatusCode.ERROR,
|
|
118
|
+
message: sanitized ?? message,
|
|
121
119
|
});
|
|
122
|
-
span.setStatus({ code: SpanStatusCode.ERROR, message });
|
|
123
120
|
span.end();
|
|
124
|
-
|
|
125
|
-
recordRequestDuration(duration, effectiveProvider, effectiveModel);
|
|
126
|
-
recordError(effectiveProvider, effectiveModel);
|
|
121
|
+
handleErrorMetrics(err, duration, effectiveProvider, effectiveModel, budget, estimatedCost, config?.attributes);
|
|
127
122
|
throw err;
|
|
128
123
|
}
|
|
129
124
|
// Some SDKs (Gemini) return { stream: AsyncIterable } instead of a direct AsyncIterable
|
|
@@ -179,42 +174,23 @@ original, patch, providerName) {
|
|
|
179
174
|
else {
|
|
180
175
|
span.setStatus({ code: SpanStatusCode.OK });
|
|
181
176
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (pricing?.maxContextTokens && acc.inputTokens > 0) {
|
|
197
|
-
const utilization = acc.inputTokens / pricing.maxContextTokens;
|
|
198
|
-
span.setAttribute(GEN_AI_ATTRS.CONTEXT_UTILIZATION, utilization);
|
|
199
|
-
recordContextUtilization(utilization, effectiveProvider, effectiveModel);
|
|
200
|
-
// Context guard — post-stream warning
|
|
201
|
-
const guard = config?.contextGuard;
|
|
202
|
-
if (guard) {
|
|
203
|
-
if (guard.alertAt !== undefined && utilization >= guard.alertAt) {
|
|
204
|
-
recordContextBlocked(effectiveModel);
|
|
205
|
-
span.addEvent("gen_ai.context.limit_exceeded", {
|
|
206
|
-
"gen_ai.toad_eye.context_utilization": utilization,
|
|
207
|
-
"gen_ai.toad_eye.context.threshold": guard.alertAt,
|
|
208
|
-
});
|
|
209
|
-
console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${effectiveModel} — exceeds alertAt threshold ${(guard.alertAt * 100).toFixed(0)}%. Compress context before next call.`);
|
|
210
|
-
}
|
|
211
|
-
else if (guard.warnAt !== undefined &&
|
|
212
|
-
utilization >= guard.warnAt) {
|
|
213
|
-
console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${effectiveModel} (${acc.inputTokens}/${pricing.maxContextTokens} tokens)`);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
177
|
+
const attrs = config?.attributes;
|
|
178
|
+
recordSuccessMetrics({
|
|
179
|
+
duration,
|
|
180
|
+
provider: effectiveProvider,
|
|
181
|
+
model: effectiveModel,
|
|
182
|
+
cost,
|
|
183
|
+
inputTokens: acc.inputTokens,
|
|
184
|
+
outputTokens: acc.outputTokens,
|
|
185
|
+
completion: acc.completion,
|
|
186
|
+
attrs,
|
|
187
|
+
});
|
|
188
|
+
// SAFETY finish reason is an error for metrics
|
|
189
|
+
if (acc.finishReason === "SAFETY") {
|
|
190
|
+
recordError(effectiveProvider, effectiveModel, attrs);
|
|
216
191
|
}
|
|
217
|
-
|
|
192
|
+
evaluateContextGuard(span, effectiveModel, effectiveProvider, acc.inputTokens);
|
|
193
|
+
// Prefill/decode latency split (streaming-specific)
|
|
218
194
|
if (ttftMs > 0) {
|
|
219
195
|
const decodeMs = duration - ttftMs;
|
|
220
196
|
span.setAttribute("gen_ai.toad_eye.latency.decode_ms", decodeMs);
|
|
@@ -222,29 +198,26 @@ original, patch, providerName) {
|
|
|
222
198
|
span.setAttribute("gen_ai.toad_eye.throughput.tokens_per_second", acc.outputTokens / (decodeMs / 1000));
|
|
223
199
|
}
|
|
224
200
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (exceeded) {
|
|
229
|
-
recordBudgetExceeded(exceeded.budget);
|
|
230
|
-
console.warn(`toad-eye: ${exceeded.budget} budget exceeded — limit $${exceeded.limit}, current $${exceeded.current.toFixed(2)}`);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
201
|
+
recordBudgetPostCheck(budget, cost, effectiveModel, userId, estimatedCost);
|
|
202
|
+
// span.end() AFTER all span writes including budget post-check
|
|
203
|
+
span.end();
|
|
233
204
|
}, (err) => {
|
|
234
205
|
const duration = performance.now() - start;
|
|
235
206
|
const message = err instanceof Error ? err.message : String(err);
|
|
236
|
-
|
|
237
|
-
if (budget)
|
|
238
|
-
budget.releaseReservation(estimatedCost);
|
|
207
|
+
const sanitized = processContent(message);
|
|
239
208
|
span.setAttributes({
|
|
209
|
+
[GEN_AI_ATTRS.INPUT_TOKENS]: 0,
|
|
210
|
+
[GEN_AI_ATTRS.OUTPUT_TOKENS]: 0,
|
|
211
|
+
[GEN_AI_ATTRS.COST]: 0,
|
|
240
212
|
[GEN_AI_ATTRS.STATUS]: "error",
|
|
241
|
-
[GEN_AI_ATTRS.ERROR]:
|
|
213
|
+
...(sanitized !== undefined && { [GEN_AI_ATTRS.ERROR]: sanitized }),
|
|
214
|
+
});
|
|
215
|
+
span.setStatus({
|
|
216
|
+
code: SpanStatusCode.ERROR,
|
|
217
|
+
message: sanitized ?? message,
|
|
242
218
|
});
|
|
243
|
-
span.setStatus({ code: SpanStatusCode.ERROR, message });
|
|
244
219
|
span.end();
|
|
245
|
-
|
|
246
|
-
recordRequestDuration(duration, effectiveProvider, effectiveModel);
|
|
247
|
-
recordError(effectiveProvider, effectiveModel);
|
|
220
|
+
handleErrorMetrics(err, duration, effectiveProvider, effectiveModel, budget, estimatedCost, config?.attributes);
|
|
248
221
|
}));
|
|
249
222
|
// Proxy preserves the original object shape (getters, private fields, methods)
|
|
250
223
|
// while intercepting only the async iterator for instrumentation
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toad-eye",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"description": "Observability for MCP servers and LLM applications — traces, metrics, dashboards. One-line instrumentation for OpenAI, Anthropic, Gemini, and MCP. Self-hosted with Grafana + Prometheus + Jaeger.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|