toad-eye 2.2.0 → 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.
- package/README.md +166 -152
- package/dist/agent.d.ts +19 -28
- package/dist/agent.js +46 -27
- package/dist/alerts/channels.js +24 -6
- package/dist/alerts/index.js +46 -2
- package/dist/alerts/manager.d.ts +1 -0
- package/dist/alerts/manager.js +36 -21
- package/dist/budget/tracker.d.ts +16 -6
- package/dist/budget/tracker.js +38 -17
- package/dist/budget/types.d.ts +2 -0
- package/dist/cli.d.ts +8 -1
- package/dist/cli.js +106 -54
- package/dist/core/metrics.d.ts +4 -0
- package/dist/core/metrics.js +19 -0
- package/dist/core/pricing.d.ts +2 -0
- package/dist/core/pricing.js +4 -0
- package/dist/core/spans.d.ts +5 -3
- package/dist/core/spans.js +79 -20
- package/dist/core/tracer.d.ts +0 -3
- package/dist/core/tracer.js +40 -7
- package/dist/drift/baseline.js +7 -1
- package/dist/drift/monitor.js +6 -6
- package/dist/index.d.ts +7 -2
- package/dist/index.js +6 -1
- package/dist/instrumentations/anthropic.d.ts +1 -1
- package/dist/instrumentations/anthropic.js +2 -3
- package/dist/instrumentations/create.js +80 -19
- package/dist/instrumentations/gemini.d.ts +1 -1
- package/dist/instrumentations/gemini.js +6 -7
- package/dist/instrumentations/openai.d.ts +1 -1
- package/dist/instrumentations/openai.js +2 -3
- package/dist/instrumentations/registry.d.ts +3 -1
- package/dist/instrumentations/registry.js +28 -5
- package/dist/instrumentations/types.d.ts +4 -2
- package/dist/types/attributes.d.ts +3 -0
- package/dist/types/attributes.js +3 -0
- package/dist/types/config.d.ts +22 -2
- package/dist/types/index.d.ts +3 -3
- package/dist/types/metrics.d.ts +2 -0
- package/dist/types/metrics.js +3 -0
- package/dist/types/providers.d.ts +6 -1
- package/dist/types/spans.d.ts +11 -2
- package/dist/vercel.d.ts +53 -0
- package/dist/vercel.js +106 -0
- package/package.json +29 -3
- package/templates/grafana/dashboards/agent-workflow.json +207 -0
- package/templates/otel-collector.yml +25 -1
package/dist/core/spans.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { LLMProvider } from "../types/index.js";
|
|
2
2
|
/** Input for traceLLMCall — what the user knows before calling the LLM */
|
|
3
3
|
export interface LLMCallInput {
|
|
4
|
-
readonly provider:
|
|
4
|
+
readonly provider: LLMProvider;
|
|
5
5
|
readonly model: string;
|
|
6
6
|
readonly prompt: string;
|
|
7
7
|
readonly temperature?: number | undefined;
|
|
@@ -13,6 +13,8 @@ export interface LLMCallOutput {
|
|
|
13
13
|
readonly completion: string;
|
|
14
14
|
readonly inputTokens: number;
|
|
15
15
|
readonly outputTokens: number;
|
|
16
|
-
|
|
16
|
+
/** Cost in USD. If omitted, auto-calculated from model pricing table. */
|
|
17
|
+
readonly cost?: number | undefined;
|
|
17
18
|
}
|
|
19
|
+
export declare function processContent(text: string): string | undefined;
|
|
18
20
|
export declare function traceLLMCall(input: LLMCallInput, fn: () => Promise<LLMCallOutput>): Promise<LLMCallOutput>;
|
package/dist/core/spans.js
CHANGED
|
@@ -1,32 +1,73 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { trace,
|
|
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, } from "./metrics.js";
|
|
4
|
+
import { recordRequestDuration, recordRequestCost, recordTokens, recordRequest, recordError, recordBudgetExceeded, recordBudgetBlocked, recordBudgetDowngraded, recordResponseEmpty, recordResponseLatencyPerToken, } from "./metrics.js";
|
|
5
5
|
import { getConfig, getBudgetTracker } from "./tracer.js";
|
|
6
|
-
import {
|
|
6
|
+
import { calculateCost } from "./pricing.js";
|
|
7
|
+
import { ToadBudgetExceededError } from "../budget/index.js";
|
|
7
8
|
const tracer = trace.getTracer(INSTRUMENTATION_NAME);
|
|
8
9
|
let saltWarningEmitted = false;
|
|
10
|
+
// Track the last config ref so saltWarningEmitted resets across re-inits without circular imports
|
|
11
|
+
let lastConfigRef = null;
|
|
9
12
|
function sha256(text) {
|
|
10
13
|
const config = getConfig();
|
|
11
14
|
const salt = config?.salt ?? "";
|
|
15
|
+
// Auto-reset saltWarningEmitted when SDK is re-initialized (config ref changes)
|
|
16
|
+
if (config !== lastConfigRef) {
|
|
17
|
+
lastConfigRef = config;
|
|
18
|
+
saltWarningEmitted = false;
|
|
19
|
+
}
|
|
12
20
|
if (config?.hashContent && !config.salt && !saltWarningEmitted) {
|
|
13
|
-
|
|
21
|
+
console.warn("toad-eye: hashContent is enabled without salt — short strings may be reversible. Set salt in config for stronger privacy.");
|
|
14
22
|
saltWarningEmitted = true;
|
|
15
23
|
}
|
|
16
24
|
return createHash("sha256")
|
|
17
25
|
.update(salt + text)
|
|
18
26
|
.digest("hex");
|
|
19
27
|
}
|
|
20
|
-
|
|
28
|
+
// Built-in PII patterns — enabled via redactDefaults: true
|
|
29
|
+
// Note: no `g` flag here — a fresh RegExp is created per call to avoid lastIndex state issues
|
|
30
|
+
const DEFAULT_REDACT_PATTERNS = [
|
|
31
|
+
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/, // email
|
|
32
|
+
/\b\d{3}-\d{2}-\d{4}\b/, // SSN (US)
|
|
33
|
+
/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/, // credit card
|
|
34
|
+
/\b\+?\d{1,3}[\s.-]?\(?\d{2,4}\)?[\s.-]?\d{3,4}[\s.-]?\d{3,4}\b/, // phone
|
|
35
|
+
];
|
|
36
|
+
function applyRedaction(text, pattern) {
|
|
37
|
+
let count = 0;
|
|
38
|
+
const regex = new RegExp(pattern.source, "g");
|
|
39
|
+
const result = text.replace(regex, () => {
|
|
40
|
+
count++;
|
|
41
|
+
return "[REDACTED]";
|
|
42
|
+
});
|
|
43
|
+
return [result, count];
|
|
44
|
+
}
|
|
45
|
+
export function processContent(text) {
|
|
21
46
|
const config = getConfig();
|
|
22
47
|
if (config?.recordContent === false)
|
|
23
48
|
return undefined;
|
|
24
49
|
let processed = text;
|
|
50
|
+
let totalRedacted = 0;
|
|
51
|
+
// Apply default PII patterns if enabled
|
|
52
|
+
if (config?.redactDefaults) {
|
|
53
|
+
for (const pattern of DEFAULT_REDACT_PATTERNS) {
|
|
54
|
+
const [result, count] = applyRedaction(processed, pattern);
|
|
55
|
+
processed = result;
|
|
56
|
+
totalRedacted += count;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Apply custom patterns
|
|
25
60
|
if (config?.redactPatterns?.length) {
|
|
26
61
|
for (const pattern of config.redactPatterns) {
|
|
27
|
-
|
|
62
|
+
const [result, count] = applyRedaction(processed, pattern);
|
|
63
|
+
processed = result;
|
|
64
|
+
totalRedacted += count;
|
|
28
65
|
}
|
|
29
66
|
}
|
|
67
|
+
// Audit mode — log a summary only, never the original content (would re-expose PII)
|
|
68
|
+
if (config?.auditMasking && totalRedacted > 0) {
|
|
69
|
+
console.log(`[toad-eye audit] Content masked: ${totalRedacted} pattern(s) applied, ${text.length} chars → ${processed.length} chars`);
|
|
70
|
+
}
|
|
30
71
|
if (config?.hashContent) {
|
|
31
72
|
return `sha256:${sha256(processed)}`;
|
|
32
73
|
}
|
|
@@ -62,14 +103,14 @@ function recordBaseMetrics(duration, provider, model, attrs) {
|
|
|
62
103
|
recordRequest(provider, model, attrs);
|
|
63
104
|
recordRequestDuration(duration, provider, model, attrs);
|
|
64
105
|
}
|
|
65
|
-
function setSuccessAttributes(span, input, output) {
|
|
106
|
+
function setSuccessAttributes(span, input, output, resolvedCost) {
|
|
66
107
|
const completion = processContent(output.completion);
|
|
67
108
|
span.setAttributes({
|
|
68
109
|
...(completion !== undefined && { [GEN_AI_ATTRS.COMPLETION]: completion }),
|
|
69
110
|
[GEN_AI_ATTRS.RESPONSE_MODEL]: input.model,
|
|
70
111
|
[GEN_AI_ATTRS.INPUT_TOKENS]: output.inputTokens,
|
|
71
112
|
[GEN_AI_ATTRS.OUTPUT_TOKENS]: output.outputTokens,
|
|
72
|
-
[GEN_AI_ATTRS.COST]:
|
|
113
|
+
[GEN_AI_ATTRS.COST]: resolvedCost,
|
|
73
114
|
[GEN_AI_ATTRS.STATUS]: "success",
|
|
74
115
|
[GEN_AI_ATTRS.FINISH_REASONS]: ["stop"],
|
|
75
116
|
});
|
|
@@ -90,12 +131,18 @@ function setErrorAttributes(span, message) {
|
|
|
90
131
|
});
|
|
91
132
|
}
|
|
92
133
|
export async function traceLLMCall(input, fn) {
|
|
134
|
+
if (!getConfig()) {
|
|
135
|
+
console.warn("toad-eye: traceLLMCall called before initObservability() — no telemetry will be recorded.");
|
|
136
|
+
}
|
|
93
137
|
const budget = getBudgetTracker();
|
|
94
138
|
let effectiveInput = input;
|
|
139
|
+
// Estimated cost for reservation — reduces race window for concurrent requests.
|
|
140
|
+
// Uses conservative token estimates; actual cost is reconciled in recordCost.
|
|
141
|
+
const estimatedCost = budget ? calculateCost(input.model, 500, 200) : 0;
|
|
95
142
|
// Budget check BEFORE the LLM call
|
|
96
143
|
if (budget) {
|
|
97
|
-
const userId = input.attributes?.[
|
|
98
|
-
const override = budget.checkBefore(input.provider, input.model, userId);
|
|
144
|
+
const userId = input.attributes?.[GEN_AI_ATTRS.USER_ID];
|
|
145
|
+
const override = budget.checkBefore(input.provider, input.model, userId, estimatedCost);
|
|
99
146
|
if (override) {
|
|
100
147
|
// Downgrade mode — use modified provider/model
|
|
101
148
|
effectiveInput = {
|
|
@@ -103,7 +150,7 @@ export async function traceLLMCall(input, fn) {
|
|
|
103
150
|
provider: override.provider,
|
|
104
151
|
model: override.model,
|
|
105
152
|
};
|
|
106
|
-
recordBudgetDowngraded(
|
|
153
|
+
recordBudgetDowngraded(override.budget);
|
|
107
154
|
}
|
|
108
155
|
}
|
|
109
156
|
return tracer.startActiveSpan(`gen_ai.${effectiveInput.provider}.${effectiveInput.model}`, async (span) => {
|
|
@@ -113,14 +160,23 @@ export async function traceLLMCall(input, fn) {
|
|
|
113
160
|
const output = await fn();
|
|
114
161
|
const duration = performance.now() - start;
|
|
115
162
|
const attrs = resolveAttributes(effectiveInput);
|
|
116
|
-
|
|
163
|
+
const resolvedCost = output.cost ??
|
|
164
|
+
calculateCost(effectiveInput.model, output.inputTokens, output.outputTokens);
|
|
165
|
+
setSuccessAttributes(span, effectiveInput, output, resolvedCost);
|
|
117
166
|
recordBaseMetrics(duration, effectiveInput.provider, effectiveInput.model, attrs);
|
|
118
|
-
recordRequestCost(
|
|
167
|
+
recordRequestCost(resolvedCost, effectiveInput.provider, effectiveInput.model, attrs);
|
|
119
168
|
recordTokens(output.inputTokens + output.outputTokens, effectiveInput.provider, effectiveInput.model, attrs);
|
|
120
|
-
//
|
|
169
|
+
// Proxy quality metrics — no extra dependencies, response text analysis only
|
|
170
|
+
if (output.completion.trim() === "") {
|
|
171
|
+
recordResponseEmpty(effectiveInput.provider, effectiveInput.model, attrs);
|
|
172
|
+
}
|
|
173
|
+
if (output.outputTokens > 0) {
|
|
174
|
+
recordResponseLatencyPerToken(duration / output.outputTokens, effectiveInput.provider, effectiveInput.model, attrs);
|
|
175
|
+
}
|
|
176
|
+
// Budget recording AFTER the call — releases the cost reservation
|
|
121
177
|
if (budget) {
|
|
122
|
-
const userId = effectiveInput.attributes?.[
|
|
123
|
-
const exceeded = budget.recordCost(
|
|
178
|
+
const userId = effectiveInput.attributes?.[GEN_AI_ATTRS.USER_ID];
|
|
179
|
+
const exceeded = budget.recordCost(resolvedCost, effectiveInput.model, userId, estimatedCost);
|
|
124
180
|
if (exceeded) {
|
|
125
181
|
recordBudgetExceeded(exceeded.budget);
|
|
126
182
|
console.warn(`toad-eye: ${exceeded.budget} budget exceeded — limit $${exceeded.limit}, current $${exceeded.current.toFixed(2)}`);
|
|
@@ -135,10 +191,13 @@ export async function traceLLMCall(input, fn) {
|
|
|
135
191
|
setErrorAttributes(span, message);
|
|
136
192
|
recordBaseMetrics(duration, effectiveInput.provider, effectiveInput.model, attrs);
|
|
137
193
|
recordError(effectiveInput.provider, effectiveInput.model, attrs);
|
|
138
|
-
//
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
194
|
+
// Release cost reservation on any error (no cost was incurred)
|
|
195
|
+
if (budget) {
|
|
196
|
+
budget.releaseReservation(estimatedCost);
|
|
197
|
+
}
|
|
198
|
+
// If this was a budget block, record the metric with the correct budget type
|
|
199
|
+
if (error instanceof ToadBudgetExceededError) {
|
|
200
|
+
recordBudgetBlocked(error.budget);
|
|
142
201
|
}
|
|
143
202
|
throw error;
|
|
144
203
|
}
|
package/dist/core/tracer.d.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import type { ToadEyeConfig } from "../types/index.js";
|
|
2
2
|
import { BudgetTracker } from "../budget/index.js";
|
|
3
|
-
import "../instrumentations/openai.js";
|
|
4
|
-
import "../instrumentations/anthropic.js";
|
|
5
|
-
import "../instrumentations/gemini.js";
|
|
6
3
|
export declare function getConfig(): ToadEyeConfig | null;
|
|
7
4
|
export declare function getBudgetTracker(): BudgetTracker | null;
|
|
8
5
|
export declare function initObservability(config: ToadEyeConfig): void;
|
package/dist/core/tracer.js
CHANGED
|
@@ -3,14 +3,13 @@ import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
|
3
3
|
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
4
4
|
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
|
5
5
|
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
|
6
|
+
import { TraceIdRatioBasedSampler, ParentBasedSampler, } from "@opentelemetry/sdk-trace-node";
|
|
6
7
|
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
|
7
|
-
import { initMetrics } from "./metrics.js";
|
|
8
|
+
import { initMetrics, resetMetrics } from "./metrics.js";
|
|
9
|
+
import { resetCustomPricing } from "./pricing.js";
|
|
8
10
|
import { enableAll, disableAll } from "../instrumentations/registry.js";
|
|
9
11
|
import { BudgetTracker } from "../budget/index.js";
|
|
10
|
-
|
|
11
|
-
import "../instrumentations/openai.js";
|
|
12
|
-
import "../instrumentations/anthropic.js";
|
|
13
|
-
import "../instrumentations/gemini.js";
|
|
12
|
+
import { ToadEyeAISpanProcessor } from "../vercel.js";
|
|
14
13
|
const DEFAULT_ENDPOINT = "http://localhost:4318";
|
|
15
14
|
const DEFAULT_CLOUD_ENDPOINT = "https://cloud.toad-eye.dev";
|
|
16
15
|
let sdk = null;
|
|
@@ -45,8 +44,10 @@ function resolveTransport(config) {
|
|
|
45
44
|
return { endpoint, headers, isCloudMode };
|
|
46
45
|
}
|
|
47
46
|
export function initObservability(config) {
|
|
48
|
-
if (sdk)
|
|
47
|
+
if (sdk) {
|
|
48
|
+
console.warn("toad-eye: initObservability() already called. Call shutdown() first to reconfigure.");
|
|
49
49
|
return;
|
|
50
|
+
}
|
|
50
51
|
validateConfig(config);
|
|
51
52
|
const { endpoint, headers, isCloudMode } = resolveTransport(config);
|
|
52
53
|
const resource = resourceFromAttributes({
|
|
@@ -64,14 +65,38 @@ export function initObservability(config) {
|
|
|
64
65
|
exporter: metricExporter,
|
|
65
66
|
exportIntervalMillis: isCloudMode ? 10_000 : 5_000,
|
|
66
67
|
});
|
|
68
|
+
// Add Vercel AI SDK SpanProcessor if 'ai' is in instrument list
|
|
69
|
+
const spanProcessors = config.instrument?.includes("ai")
|
|
70
|
+
? [new ToadEyeAISpanProcessor()]
|
|
71
|
+
: [];
|
|
72
|
+
// SDK-side head sampling (default: 1.0 = send everything to Collector)
|
|
73
|
+
const sdkRate = config.sampling?.sdkRate ?? 1.0;
|
|
74
|
+
const sampler = sdkRate < 1.0
|
|
75
|
+
? new ParentBasedSampler({
|
|
76
|
+
root: new TraceIdRatioBasedSampler(sdkRate),
|
|
77
|
+
})
|
|
78
|
+
: undefined;
|
|
67
79
|
sdk = new NodeSDK({
|
|
68
80
|
resource,
|
|
69
81
|
traceExporter,
|
|
70
82
|
metricReader,
|
|
83
|
+
spanProcessors,
|
|
84
|
+
...(sampler !== undefined && { sampler }),
|
|
71
85
|
});
|
|
72
86
|
sdk.start();
|
|
73
87
|
currentConfig = config;
|
|
74
88
|
initMetrics();
|
|
89
|
+
// Non-blocking connectivity check — warns the user if the OTel Collector is unreachable.
|
|
90
|
+
// Fire-and-forget: does not block initObservability(). Cloud mode skips this check.
|
|
91
|
+
if (!isCloudMode) {
|
|
92
|
+
void fetch(`${endpoint}/v1/traces`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
body: "[]",
|
|
95
|
+
signal: AbortSignal.timeout(2000),
|
|
96
|
+
}).catch(() => {
|
|
97
|
+
console.warn(`toad-eye: cannot reach OTel Collector at ${endpoint} — no telemetry will be exported. Is the stack running? Run: npx toad-eye up`);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
75
100
|
if (isCloudMode) {
|
|
76
101
|
const masked = config.apiKey
|
|
77
102
|
? config.apiKey.slice(0, 8) + "..." + config.apiKey.slice(-4)
|
|
@@ -82,7 +107,13 @@ export function initObservability(config) {
|
|
|
82
107
|
budgetTracker = new BudgetTracker(config.budgets, config.onBudgetExceeded ?? "warn", config.downgradeCallback);
|
|
83
108
|
}
|
|
84
109
|
if (config.instrument?.length) {
|
|
85
|
-
|
|
110
|
+
// Filter out 'ai' — it uses SpanProcessor, not monkey-patching
|
|
111
|
+
const patchProviders = config.instrument.filter((i) => i !== "ai");
|
|
112
|
+
if (patchProviders.length > 0) {
|
|
113
|
+
// enableAll is async (lazy-loads provider modules on first call).
|
|
114
|
+
// Fire-and-forget: patching completes before any async SDK call.
|
|
115
|
+
void enableAll(patchProviders);
|
|
116
|
+
}
|
|
86
117
|
}
|
|
87
118
|
}
|
|
88
119
|
export async function shutdown() {
|
|
@@ -93,4 +124,6 @@ export async function shutdown() {
|
|
|
93
124
|
sdk = null;
|
|
94
125
|
currentConfig = null;
|
|
95
126
|
budgetTracker = null;
|
|
127
|
+
resetMetrics();
|
|
128
|
+
resetCustomPricing();
|
|
96
129
|
}
|
package/dist/drift/baseline.js
CHANGED
|
@@ -12,5 +12,11 @@ export function loadBaseline(filepath) {
|
|
|
12
12
|
if (!existsSync(filepath))
|
|
13
13
|
return undefined;
|
|
14
14
|
const raw = readFileSync(filepath, "utf-8");
|
|
15
|
-
|
|
15
|
+
const parsed = JSON.parse(raw);
|
|
16
|
+
if (typeof parsed !== "object" ||
|
|
17
|
+
parsed === null ||
|
|
18
|
+
!Array.isArray(parsed["embeddings"])) {
|
|
19
|
+
throw new Error(`toad-eye: baseline file at "${filepath}" is malformed — missing "embeddings" array`);
|
|
20
|
+
}
|
|
21
|
+
return parsed;
|
|
16
22
|
}
|
package/dist/drift/monitor.js
CHANGED
|
@@ -39,13 +39,13 @@ export function createDriftMonitor(config) {
|
|
|
39
39
|
return {
|
|
40
40
|
check: performCheck,
|
|
41
41
|
checkInBackground(response, providerName, model) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
]).catch((err) => {
|
|
42
|
+
let timer;
|
|
43
|
+
const timeout = new Promise((_, reject) => (timer = setTimeout(() => reject(new Error("Drift check timeout (5s)")), 5000)));
|
|
44
|
+
void Promise.race([performCheck(response, providerName, model), timeout])
|
|
45
|
+
.catch((err) => {
|
|
47
46
|
console.warn(`[toad-eye] Drift check failed (non-blocking): ${err instanceof Error ? err.message : String(err)}`);
|
|
48
|
-
})
|
|
47
|
+
})
|
|
48
|
+
.finally(() => clearTimeout(timer));
|
|
49
49
|
},
|
|
50
50
|
};
|
|
51
51
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -13,5 +13,10 @@ export type { ExportTraceOptions } from "./export.js";
|
|
|
13
13
|
export { calculateCost, setCustomPricing, getModelPricing, } from "./core/pricing.js";
|
|
14
14
|
export type { ModelPricing } from "./core/pricing.js";
|
|
15
15
|
export type { LLMCallInput, LLMCallOutput } from "./core/spans.js";
|
|
16
|
-
export type { ToadEyeConfig, LLMSpanAttributes, LLMProvider, SpanStatus, MetricName, AgentStepType, AgentStepInput, GuardMode, GuardResult, } from "./types/index.js";
|
|
17
|
-
export { GEN_AI_METRICS, GEN_AI_ATTRS
|
|
16
|
+
export type { ToadEyeConfig, LLMSpanAttributes, LLMProvider, SpanStatus, MetricName, AgentStepType, AgentStepInput, AgentQueryOptions, GuardMode, GuardResult, InstrumentTarget, SamplingConfig, } from "./types/index.js";
|
|
17
|
+
export { GEN_AI_METRICS, GEN_AI_ATTRS } from "./types/index.js";
|
|
18
|
+
/** @deprecated Use GEN_AI_METRICS instead */
|
|
19
|
+
export { LLM_METRICS } from "./types/index.js";
|
|
20
|
+
/** @deprecated Use GEN_AI_ATTRS instead */
|
|
21
|
+
export { LLM_ATTRS } from "./types/index.js";
|
|
22
|
+
export { ToadEyeAISpanProcessor, withToadEye } from "./vercel.js";
|
package/dist/index.js
CHANGED
|
@@ -7,4 +7,9 @@ export { recordGuardResult } from "./guard.js";
|
|
|
7
7
|
export { createDriftMonitor, createOpenAIEmbeddingProvider, cosineSimilarity, cosineDrift, saveBaseline, loadBaseline, } from "./drift/index.js";
|
|
8
8
|
export { exportTrace, fetchTrace, traceToEvalYaml } from "./export.js";
|
|
9
9
|
export { calculateCost, setCustomPricing, getModelPricing, } from "./core/pricing.js";
|
|
10
|
-
export { GEN_AI_METRICS, GEN_AI_ATTRS
|
|
10
|
+
export { GEN_AI_METRICS, GEN_AI_ATTRS } from "./types/index.js";
|
|
11
|
+
/** @deprecated Use GEN_AI_METRICS instead */
|
|
12
|
+
export { LLM_METRICS } from "./types/index.js";
|
|
13
|
+
/** @deprecated Use GEN_AI_ATTRS instead */
|
|
14
|
+
export { LLM_ATTRS } from "./types/index.js";
|
|
15
|
+
export { ToadEyeAISpanProcessor, withToadEye } from "./vercel.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export
|
|
1
|
+
export declare const anthropicInstrumentation: 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))
|
|
@@ -59,8 +58,8 @@ const messagesCreate = {
|
|
|
59
58
|
}
|
|
60
59
|
},
|
|
61
60
|
};
|
|
62
|
-
|
|
61
|
+
export const anthropicInstrumentation = createInstrumentation({
|
|
63
62
|
name: "anthropic",
|
|
64
63
|
moduleName: "@anthropic-ai/sdk",
|
|
65
64
|
patches: [messagesCreate],
|
|
66
|
-
})
|
|
65
|
+
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { trace, context, diag, SpanStatusCode, } from "@opentelemetry/api";
|
|
3
|
-
import { traceLLMCall } from "../core/spans.js";
|
|
3
|
+
import { traceLLMCall, processContent } from "../core/spans.js";
|
|
4
4
|
import { calculateCost } from "../core/pricing.js";
|
|
5
|
-
import { recordRequestDuration, recordRequestCost, recordTokens, recordRequest, recordError, recordTimeToFirstToken, } 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) {
|
|
@@ -64,17 +66,55 @@ function createStreamingHandler(
|
|
|
64
66
|
original, patch, providerName) {
|
|
65
67
|
const tracer = trace.getTracer(INSTRUMENTATION_NAME);
|
|
66
68
|
return async function handleStreaming(thisArg, body, rest) {
|
|
67
|
-
|
|
69
|
+
// Pass thisArg so extractRequest can access instance properties (e.g., Gemini model name)
|
|
70
|
+
const req = patch.extractRequest(body, thisArg);
|
|
68
71
|
const start = performance.now();
|
|
69
|
-
|
|
70
|
-
const
|
|
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}`);
|
|
71
88
|
const ctx = trace.setSpan(context.active(), span);
|
|
89
|
+
const sessionId = config?.sessionExtractor?.() ?? config?.sessionId;
|
|
72
90
|
span.setAttributes({
|
|
73
|
-
[GEN_AI_ATTRS.PROVIDER]:
|
|
74
|
-
[GEN_AI_ATTRS.REQUEST_MODEL]:
|
|
91
|
+
[GEN_AI_ATTRS.PROVIDER]: effectiveProvider,
|
|
92
|
+
[GEN_AI_ATTRS.REQUEST_MODEL]: effectiveModel,
|
|
75
93
|
[GEN_AI_ATTRS.TEMPERATURE]: req.temperature ?? 1.0,
|
|
76
94
|
[GEN_AI_ATTRS.OPERATION]: "chat",
|
|
95
|
+
...(sessionId !== undefined && { [GEN_AI_ATTRS.SESSION_ID]: sessionId }),
|
|
77
96
|
});
|
|
97
|
+
let response;
|
|
98
|
+
try {
|
|
99
|
+
response = await original.call(thisArg, body, ...rest);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
const duration = performance.now() - start;
|
|
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);
|
|
107
|
+
span.setAttributes({
|
|
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
|
+
}
|
|
78
118
|
// Some SDKs (Gemini) return { stream: AsyncIterable } instead of a direct AsyncIterable
|
|
79
119
|
const resp = response;
|
|
80
120
|
const hasStreamProp = resp != null &&
|
|
@@ -87,13 +127,16 @@ original, patch, providerName) {
|
|
|
87
127
|
? resp["stream"]
|
|
88
128
|
: response;
|
|
89
129
|
const wrapped = context.bind(ctx, wrapAsyncIterable(streamIterable, (acc, chunk) => patch.accumulateChunk(acc, chunk), () => {
|
|
90
|
-
|
|
91
|
-
recordTimeToFirstToken(ttft, providerName, req.model);
|
|
130
|
+
recordTimeToFirstToken(performance.now() - start, effectiveProvider, effectiveModel);
|
|
92
131
|
}, (acc) => {
|
|
93
132
|
const duration = performance.now() - start;
|
|
94
|
-
const cost = calculateCost(
|
|
133
|
+
const cost = calculateCost(effectiveModel, acc.inputTokens, acc.outputTokens);
|
|
134
|
+
const processedCompletion = processContent(acc.completion);
|
|
95
135
|
span.setAttributes({
|
|
96
|
-
|
|
136
|
+
...(processedCompletion !== undefined && {
|
|
137
|
+
[GEN_AI_ATTRS.COMPLETION]: processedCompletion,
|
|
138
|
+
}),
|
|
139
|
+
[GEN_AI_ATTRS.RESPONSE_MODEL]: effectiveModel,
|
|
97
140
|
[GEN_AI_ATTRS.INPUT_TOKENS]: acc.inputTokens,
|
|
98
141
|
[GEN_AI_ATTRS.OUTPUT_TOKENS]: acc.outputTokens,
|
|
99
142
|
[GEN_AI_ATTRS.COST]: cost,
|
|
@@ -102,22 +145,40 @@ original, patch, providerName) {
|
|
|
102
145
|
});
|
|
103
146
|
span.setStatus({ code: SpanStatusCode.OK });
|
|
104
147
|
span.end();
|
|
105
|
-
recordRequest(
|
|
106
|
-
recordRequestDuration(duration,
|
|
107
|
-
recordRequestCost(cost,
|
|
108
|
-
recordTokens(acc.inputTokens + acc.outputTokens,
|
|
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
|
+
}
|
|
109
167
|
}, (err) => {
|
|
110
168
|
const duration = performance.now() - start;
|
|
111
169
|
const message = err instanceof Error ? err.message : String(err);
|
|
170
|
+
// Release budget reservation on stream error
|
|
171
|
+
if (budget)
|
|
172
|
+
budget.releaseReservation(estimatedCost);
|
|
112
173
|
span.setAttributes({
|
|
113
174
|
[GEN_AI_ATTRS.STATUS]: "error",
|
|
114
175
|
[GEN_AI_ATTRS.ERROR]: message,
|
|
115
176
|
});
|
|
116
177
|
span.setStatus({ code: SpanStatusCode.ERROR, message });
|
|
117
178
|
span.end();
|
|
118
|
-
recordRequest(
|
|
119
|
-
recordRequestDuration(duration,
|
|
120
|
-
recordError(
|
|
179
|
+
recordRequest(effectiveProvider, effectiveModel);
|
|
180
|
+
recordRequestDuration(duration, effectiveProvider, effectiveModel);
|
|
181
|
+
recordError(effectiveProvider, effectiveModel);
|
|
121
182
|
}));
|
|
122
183
|
// Proxy preserves the original object shape (getters, private fields, methods)
|
|
123
184
|
// while intercepting only the async iterator for instrumentation
|
|
@@ -144,7 +205,7 @@ original, patch, providerName) {
|
|
|
144
205
|
if (streamHandler && patch.isStreaming?.(body)) {
|
|
145
206
|
return streamHandler(this, body, rest);
|
|
146
207
|
}
|
|
147
|
-
const req = patch.extractRequest(body);
|
|
208
|
+
const req = patch.extractRequest(body, this);
|
|
148
209
|
return traceLLMCall({
|
|
149
210
|
provider: providerName,
|
|
150
211
|
model: req.model,
|
|
@@ -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,10 +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: "unknown",
|
|
23
|
+
model: thisArg?.model ?? "unknown",
|
|
25
24
|
};
|
|
26
25
|
},
|
|
27
26
|
extractResponse: (response) => {
|
|
@@ -43,10 +42,10 @@ const generateContent = {
|
|
|
43
42
|
const generateContentStream = {
|
|
44
43
|
getPrototype: (sdk) => sdk?.GenerativeModel?.prototype,
|
|
45
44
|
method: "generateContentStream",
|
|
46
|
-
extractRequest(body) {
|
|
45
|
+
extractRequest(body, thisArg) {
|
|
47
46
|
return {
|
|
48
47
|
prompt: extractPrompt(body),
|
|
49
|
-
model: "unknown",
|
|
48
|
+
model: thisArg?.model ?? "unknown",
|
|
50
49
|
};
|
|
51
50
|
},
|
|
52
51
|
extractResponse: () => ({
|
|
@@ -74,8 +73,8 @@ const generateContentStream = {
|
|
|
74
73
|
}
|
|
75
74
|
},
|
|
76
75
|
};
|
|
77
|
-
|
|
76
|
+
export const geminiInstrumentation = createInstrumentation({
|
|
78
77
|
name: "gemini",
|
|
79
78
|
moduleName: "@google/generative-ai",
|
|
80
79
|
patches: [generateContent, generateContentStream],
|
|
81
|
-
})
|
|
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))
|
|
@@ -79,8 +78,8 @@ const embeddings = {
|
|
|
79
78
|
};
|
|
80
79
|
},
|
|
81
80
|
};
|
|
82
|
-
|
|
81
|
+
export const openaiInstrumentation = createInstrumentation({
|
|
83
82
|
name: "openai",
|
|
84
83
|
moduleName: "openai",
|
|
85
84
|
patches: [chatCompletions, embeddings],
|
|
86
|
-
})
|
|
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
|
-
|
|
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;
|