toad-eye 2.2.0 → 2.4.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 +31 -28
- package/dist/agent.js +116 -35
- 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 +20 -1
- 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 +80 -21
- package/dist/core/tracer.d.ts +6 -3
- package/dist/core/tracer.js +49 -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 +20 -0
- package/dist/types/attributes.js +23 -1
- 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 +21 -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/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
3
3
|
import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -12,10 +12,19 @@ const DEMO_TICK_INTERVAL_MS = 2000;
|
|
|
12
12
|
const DEMO_TOKEN_INPUT_RANGE = [50, 500];
|
|
13
13
|
const DEMO_TOKEN_OUTPUT_RANGE = [20, 300];
|
|
14
14
|
const SERVICES = [
|
|
15
|
-
{
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
{
|
|
16
|
+
name: "Grafana",
|
|
17
|
+
service: "grafana",
|
|
18
|
+
url: "http://localhost:3100",
|
|
19
|
+
login: "admin / admin",
|
|
20
|
+
},
|
|
21
|
+
{ name: "Jaeger UI", service: "jaeger", url: "http://localhost:16686" },
|
|
22
|
+
{ name: "Prometheus", service: "prometheus", url: "http://localhost:9090" },
|
|
23
|
+
{
|
|
24
|
+
name: "OTel Collector",
|
|
25
|
+
service: "collector",
|
|
26
|
+
url: "http://localhost:4318",
|
|
27
|
+
},
|
|
19
28
|
];
|
|
20
29
|
function getTemplatesDir() {
|
|
21
30
|
const thisFile = fileURLToPath(import.meta.url);
|
|
@@ -35,8 +44,9 @@ function requireInfra(message = `Run \`npx toad-eye init\` first.`) {
|
|
|
35
44
|
}
|
|
36
45
|
function init() {
|
|
37
46
|
const dest = join(process.cwd(), INFRA_DIR);
|
|
38
|
-
|
|
39
|
-
|
|
47
|
+
const force = process.argv.includes("--force");
|
|
48
|
+
if (existsSync(dest) && !force) {
|
|
49
|
+
console.log(`✅ ${INFRA_DIR}/ already exists, skipping. Use --force to overwrite.`);
|
|
40
50
|
return;
|
|
41
51
|
}
|
|
42
52
|
const templatesDir = getTemplatesDir();
|
|
@@ -44,39 +54,59 @@ function init() {
|
|
|
44
54
|
console.error(`\u274c Templates not found at ${templatesDir}. Reinstall toad-eye.`);
|
|
45
55
|
process.exit(1);
|
|
46
56
|
}
|
|
57
|
+
const existed = existsSync(dest);
|
|
47
58
|
mkdirSync(dest, { recursive: true });
|
|
48
59
|
cpSync(templatesDir, dest, { recursive: true });
|
|
49
|
-
|
|
60
|
+
const action = force && existed ? "Updated" : "Created";
|
|
61
|
+
console.log(`✅ ${action} ${INFRA_DIR}/ with observability stack config.`);
|
|
50
62
|
console.log();
|
|
51
63
|
console.log("Next steps:");
|
|
52
64
|
console.log(" npx toad-eye up Start the stack");
|
|
53
65
|
console.log(" npx toad-eye status Check running services");
|
|
54
66
|
}
|
|
67
|
+
function requireDocker() {
|
|
68
|
+
try {
|
|
69
|
+
execFileSync("docker", ["--version"], { stdio: "ignore" });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
console.error("❌ Docker not found. Install Docker Desktop: https://docs.docker.com/get-started/get-docker/");
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
55
76
|
function up() {
|
|
77
|
+
requireDocker();
|
|
56
78
|
const composeFile = requireInfra();
|
|
57
|
-
console.log("
|
|
58
|
-
|
|
79
|
+
console.log("🐸 Starting observability stack...");
|
|
80
|
+
console.log(" (first run downloads ~500MB of Docker images — this may take a few minutes)\n");
|
|
81
|
+
execFileSync("docker", ["compose", "-f", composeFile, "up", "-d"], {
|
|
82
|
+
stdio: "inherit",
|
|
83
|
+
});
|
|
59
84
|
console.log();
|
|
60
85
|
status();
|
|
86
|
+
console.log(" Run 'npx toad-eye demo' to send test data and verify the stack works.");
|
|
61
87
|
}
|
|
62
88
|
function down() {
|
|
63
89
|
const composeFile = requireInfra("Nothing to stop.");
|
|
64
90
|
console.log("\u{1f44b} Stopping observability stack...");
|
|
65
|
-
|
|
91
|
+
execFileSync("docker", ["compose", "-f", composeFile, "down"], {
|
|
92
|
+
stdio: "inherit",
|
|
93
|
+
});
|
|
66
94
|
console.log("\u2705 Stack stopped.");
|
|
67
95
|
}
|
|
96
|
+
/** Find the docker container matching a service by exact service name. Exported for testing. */
|
|
97
|
+
export function findContainerByService(containers, serviceKey) {
|
|
98
|
+
return containers.find((c) => c.Service === serviceKey);
|
|
99
|
+
}
|
|
68
100
|
function status() {
|
|
69
101
|
const composeFile = requireInfra();
|
|
70
102
|
try {
|
|
71
|
-
const output =
|
|
72
|
-
encoding: "utf-8",
|
|
73
|
-
});
|
|
103
|
+
const output = execFileSync("docker", ["compose", "-f", composeFile, "ps", "--format", "json"], { encoding: "utf-8" });
|
|
74
104
|
const lines = output.trim().split("\n").filter(Boolean);
|
|
75
105
|
const containers = lines.map((line) => JSON.parse(line));
|
|
76
106
|
console.log("\u{1f438} toad-eye stack:");
|
|
77
107
|
console.log();
|
|
78
108
|
for (const svc of SERVICES) {
|
|
79
|
-
const container = containers
|
|
109
|
+
const container = findContainerByService(containers, svc.service);
|
|
80
110
|
const state = container?.State === "running" ? "\u{1f7e2}" : "\u{1f534}";
|
|
81
111
|
const loginInfo = "login" in svc ? ` (${svc.login})` : "";
|
|
82
112
|
console.log(` ${state} ${svc.name.padEnd(16)} ${svc.url}${loginInfo}`);
|
|
@@ -121,12 +151,25 @@ async function simulateLLMCall(provider, model, prompt) {
|
|
|
121
151
|
}
|
|
122
152
|
async function demo() {
|
|
123
153
|
requireInfra();
|
|
154
|
+
// Warn early if OTel Collector is unreachable — data would be silently lost otherwise
|
|
155
|
+
try {
|
|
156
|
+
await fetch("http://localhost:4318/v1/traces", {
|
|
157
|
+
method: "POST",
|
|
158
|
+
body: "[]",
|
|
159
|
+
signal: AbortSignal.timeout(2000),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
console.warn("⚠️ OTel Collector not reachable at localhost:4318 — data will not be recorded.\n Run 'npx toad-eye up' first, then retry.\n");
|
|
164
|
+
}
|
|
124
165
|
initObservability({
|
|
125
166
|
serviceName: "toad-eye-demo",
|
|
126
167
|
endpoint: "http://localhost:4318",
|
|
127
168
|
});
|
|
128
|
-
console.log("
|
|
169
|
+
console.log("🐸👁️ toad-eye demo — sending mock LLM traffic");
|
|
129
170
|
console.log(" Press Ctrl+C to stop\n");
|
|
171
|
+
console.log("✅ Stack verified! Add this to your app:\n");
|
|
172
|
+
console.log(" import { initObservability } from 'toad-eye';\n initObservability({ serviceName: 'my-app', instrument: ['openai'] });\n");
|
|
130
173
|
process.on("SIGINT", async () => {
|
|
131
174
|
console.log("\n\u{1f44b} Shutting down...");
|
|
132
175
|
await shutdown();
|
|
@@ -186,47 +229,56 @@ async function exportTraceCommand() {
|
|
|
186
229
|
}
|
|
187
230
|
}
|
|
188
231
|
function help() {
|
|
232
|
+
const cmd = (name) => name.padEnd(15);
|
|
189
233
|
console.log(`
|
|
190
|
-
|
|
234
|
+
🐸 toad-eye CLI — observability stack for LLM services
|
|
191
235
|
|
|
192
236
|
Commands:
|
|
193
|
-
init
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
237
|
+
${cmd("init")}Copy observability configs into your project
|
|
238
|
+
${cmd("init --force")}Overwrite existing configs with latest templates
|
|
239
|
+
${cmd("up")}Start the stack (OTel Collector + Prometheus + Jaeger + Grafana)
|
|
240
|
+
${cmd("down")}Stop the stack
|
|
241
|
+
${cmd("status")}Show running services and URLs
|
|
242
|
+
${cmd("demo")}Send mock LLM traffic to see data in Grafana
|
|
243
|
+
${cmd("export-trace")}Export a Jaeger trace to toad-eval YAML
|
|
244
|
+
${cmd("help")}Show this message
|
|
200
245
|
`);
|
|
201
246
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
init
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
up
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
down
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
status
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
demo
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
247
|
+
function runCli() {
|
|
248
|
+
const command = process.argv[2];
|
|
249
|
+
switch (command) {
|
|
250
|
+
case "init":
|
|
251
|
+
init();
|
|
252
|
+
break;
|
|
253
|
+
case "up":
|
|
254
|
+
up();
|
|
255
|
+
break;
|
|
256
|
+
case "down":
|
|
257
|
+
down();
|
|
258
|
+
break;
|
|
259
|
+
case "status":
|
|
260
|
+
status();
|
|
261
|
+
break;
|
|
262
|
+
case "demo":
|
|
263
|
+
demo();
|
|
264
|
+
break;
|
|
265
|
+
case "export-trace":
|
|
266
|
+
exportTraceCommand();
|
|
267
|
+
break;
|
|
268
|
+
case "help":
|
|
269
|
+
case "--help":
|
|
270
|
+
case "-h":
|
|
271
|
+
case undefined:
|
|
272
|
+
help();
|
|
273
|
+
break;
|
|
274
|
+
default:
|
|
275
|
+
console.error(`Unknown command: ${command}`);
|
|
276
|
+
help();
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Only auto-execute when run as CLI entry point, not when imported (e.g., in tests)
|
|
281
|
+
if (process.argv[1] !== undefined &&
|
|
282
|
+
fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
|
283
|
+
runCli();
|
|
232
284
|
}
|
package/dist/core/metrics.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export declare function initMetrics(): void;
|
|
2
|
+
/** Reset metrics state so the next initMetrics() creates fresh instruments. Called from shutdown(). */
|
|
3
|
+
export declare function resetMetrics(): void;
|
|
2
4
|
export declare function recordRequestDuration(ms: number, provider: string, model: string, attrs?: Record<string, string>): void;
|
|
3
5
|
export declare function recordRequestCost(usd: number, provider: string, model: string, attrs?: Record<string, string>): void;
|
|
4
6
|
export declare function recordTokens(count: number, provider: string, model: string, attrs?: Record<string, string>): void;
|
|
@@ -13,3 +15,5 @@ export declare function recordTimeToFirstToken(ms: number, provider: string, mod
|
|
|
13
15
|
export declare function recordBudgetExceeded(budgetType: string): void;
|
|
14
16
|
export declare function recordBudgetBlocked(budgetType: string): void;
|
|
15
17
|
export declare function recordBudgetDowngraded(budgetType: string): void;
|
|
18
|
+
export declare function recordResponseEmpty(provider: string, model: string, attrs?: Record<string, string>): void;
|
|
19
|
+
export declare function recordResponseLatencyPerToken(msPerToken: number, provider: string, model: string, attrs?: Record<string, string>): void;
|
package/dist/core/metrics.js
CHANGED
|
@@ -14,6 +14,8 @@ let timeToFirstToken;
|
|
|
14
14
|
let budgetExceeded;
|
|
15
15
|
let budgetBlocked;
|
|
16
16
|
let budgetDowngraded;
|
|
17
|
+
let responseEmpty;
|
|
18
|
+
let responseLatencyPerToken;
|
|
17
19
|
let initialized = false;
|
|
18
20
|
export function initMetrics() {
|
|
19
21
|
if (initialized)
|
|
@@ -64,8 +66,19 @@ export function initMetrics() {
|
|
|
64
66
|
budgetDowngraded = meter.createCounter(GEN_AI_METRICS.BUDGET_DOWNGRADED, {
|
|
65
67
|
description: "Number of LLM calls downgraded due to budget limits",
|
|
66
68
|
});
|
|
69
|
+
responseEmpty = meter.createCounter(GEN_AI_METRICS.RESPONSE_EMPTY, {
|
|
70
|
+
description: "Number of LLM responses with empty or whitespace-only completion",
|
|
71
|
+
});
|
|
72
|
+
responseLatencyPerToken = meter.createHistogram(GEN_AI_METRICS.RESPONSE_LATENCY_PER_TOKEN, {
|
|
73
|
+
description: "Generation latency normalized per output token (ms/token). Higher values indicate slower generation speed.",
|
|
74
|
+
unit: "ms",
|
|
75
|
+
});
|
|
67
76
|
initialized = true;
|
|
68
77
|
}
|
|
78
|
+
/** Reset metrics state so the next initMetrics() creates fresh instruments. Called from shutdown(). */
|
|
79
|
+
export function resetMetrics() {
|
|
80
|
+
initialized = false;
|
|
81
|
+
}
|
|
69
82
|
/** Build metric labels: provider + model + optional FinOps attributes. */
|
|
70
83
|
function baseLabels(provider, model, attrs) {
|
|
71
84
|
return {
|
|
@@ -94,7 +107,7 @@ export function recordAgentSteps(stepCount) {
|
|
|
94
107
|
}
|
|
95
108
|
export function recordAgentToolUsage(toolName) {
|
|
96
109
|
agentToolUsage.add(1, {
|
|
97
|
-
[GEN_AI_ATTRS.
|
|
110
|
+
[GEN_AI_ATTRS.TOOL_NAME]: toolName,
|
|
98
111
|
});
|
|
99
112
|
}
|
|
100
113
|
export function recordGuardEvaluation(ruleName) {
|
|
@@ -128,3 +141,9 @@ export function recordBudgetBlocked(budgetType) {
|
|
|
128
141
|
export function recordBudgetDowngraded(budgetType) {
|
|
129
142
|
budgetDowngraded.add(1, { budget_type: budgetType });
|
|
130
143
|
}
|
|
144
|
+
export function recordResponseEmpty(provider, model, attrs) {
|
|
145
|
+
responseEmpty.add(1, baseLabels(provider, model, attrs));
|
|
146
|
+
}
|
|
147
|
+
export function recordResponseLatencyPerToken(msPerToken, provider, model, attrs) {
|
|
148
|
+
responseLatencyPerToken.record(msPerToken, baseLabels(provider, model, attrs));
|
|
149
|
+
}
|
package/dist/core/pricing.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface ModelPricing {
|
|
|
12
12
|
* Useful for enterprise contracts or custom/fine-tuned models.
|
|
13
13
|
*/
|
|
14
14
|
export declare function setCustomPricing(pricing: Record<string, ModelPricing>): void;
|
|
15
|
+
/** Reset custom pricing to empty. Called from shutdown() to avoid stale state across re-inits. */
|
|
16
|
+
export declare function resetCustomPricing(): void;
|
|
15
17
|
/**
|
|
16
18
|
* Get pricing for a model. Custom pricing takes precedence.
|
|
17
19
|
* Returns undefined if model is not in any pricing table.
|
package/dist/core/pricing.js
CHANGED
|
@@ -31,6 +31,10 @@ let customPricing = {};
|
|
|
31
31
|
export function setCustomPricing(pricing) {
|
|
32
32
|
customPricing = { ...pricing };
|
|
33
33
|
}
|
|
34
|
+
/** Reset custom pricing to empty. Called from shutdown() to avoid stale state across re-inits. */
|
|
35
|
+
export function resetCustomPricing() {
|
|
36
|
+
customPricing = {};
|
|
37
|
+
}
|
|
34
38
|
/**
|
|
35
39
|
* Get pricing for a model. Custom pricing takes precedence.
|
|
36
40
|
* Returns undefined if model is not in any pricing table.
|
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,24 +150,33 @@ 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
|
-
return tracer.startActiveSpan(`
|
|
156
|
+
return tracer.startActiveSpan(`chat ${effectiveInput.model}`, async (span) => {
|
|
110
157
|
const start = performance.now();
|
|
111
158
|
setBaseAttributes(span, effectiveInput);
|
|
112
159
|
try {
|
|
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,9 +1,12 @@
|
|
|
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;
|
|
4
|
+
/**
|
|
5
|
+
* Check if deprecated semconv aliases should be emitted.
|
|
6
|
+
* When OTEL_SEMCONV_STABILITY_OPT_IN includes "gen_ai_latest_experimental",
|
|
7
|
+
* only new (canonical) attributes are emitted — deprecated aliases are skipped.
|
|
8
|
+
*/
|
|
9
|
+
export declare function shouldEmitDeprecatedAttrs(): boolean;
|
|
7
10
|
export declare function getBudgetTracker(): BudgetTracker | null;
|
|
8
11
|
export declare function initObservability(config: ToadEyeConfig): void;
|
|
9
12
|
export declare function shutdown(): Promise<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;
|
|
@@ -19,6 +18,15 @@ let budgetTracker = null;
|
|
|
19
18
|
export function getConfig() {
|
|
20
19
|
return currentConfig;
|
|
21
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Check if deprecated semconv aliases should be emitted.
|
|
23
|
+
* When OTEL_SEMCONV_STABILITY_OPT_IN includes "gen_ai_latest_experimental",
|
|
24
|
+
* only new (canonical) attributes are emitted — deprecated aliases are skipped.
|
|
25
|
+
*/
|
|
26
|
+
export function shouldEmitDeprecatedAttrs() {
|
|
27
|
+
const optIn = process.env["OTEL_SEMCONV_STABILITY_OPT_IN"] ?? "";
|
|
28
|
+
return !optIn.split(",").includes("gen_ai_latest_experimental");
|
|
29
|
+
}
|
|
22
30
|
export function getBudgetTracker() {
|
|
23
31
|
return budgetTracker;
|
|
24
32
|
}
|
|
@@ -45,8 +53,10 @@ function resolveTransport(config) {
|
|
|
45
53
|
return { endpoint, headers, isCloudMode };
|
|
46
54
|
}
|
|
47
55
|
export function initObservability(config) {
|
|
48
|
-
if (sdk)
|
|
56
|
+
if (sdk) {
|
|
57
|
+
console.warn("toad-eye: initObservability() already called. Call shutdown() first to reconfigure.");
|
|
49
58
|
return;
|
|
59
|
+
}
|
|
50
60
|
validateConfig(config);
|
|
51
61
|
const { endpoint, headers, isCloudMode } = resolveTransport(config);
|
|
52
62
|
const resource = resourceFromAttributes({
|
|
@@ -64,14 +74,38 @@ export function initObservability(config) {
|
|
|
64
74
|
exporter: metricExporter,
|
|
65
75
|
exportIntervalMillis: isCloudMode ? 10_000 : 5_000,
|
|
66
76
|
});
|
|
77
|
+
// Add Vercel AI SDK SpanProcessor if 'ai' is in instrument list
|
|
78
|
+
const spanProcessors = config.instrument?.includes("ai")
|
|
79
|
+
? [new ToadEyeAISpanProcessor()]
|
|
80
|
+
: [];
|
|
81
|
+
// SDK-side head sampling (default: 1.0 = send everything to Collector)
|
|
82
|
+
const sdkRate = config.sampling?.sdkRate ?? 1.0;
|
|
83
|
+
const sampler = sdkRate < 1.0
|
|
84
|
+
? new ParentBasedSampler({
|
|
85
|
+
root: new TraceIdRatioBasedSampler(sdkRate),
|
|
86
|
+
})
|
|
87
|
+
: undefined;
|
|
67
88
|
sdk = new NodeSDK({
|
|
68
89
|
resource,
|
|
69
90
|
traceExporter,
|
|
70
91
|
metricReader,
|
|
92
|
+
spanProcessors,
|
|
93
|
+
...(sampler !== undefined && { sampler }),
|
|
71
94
|
});
|
|
72
95
|
sdk.start();
|
|
73
96
|
currentConfig = config;
|
|
74
97
|
initMetrics();
|
|
98
|
+
// Non-blocking connectivity check — warns the user if the OTel Collector is unreachable.
|
|
99
|
+
// Fire-and-forget: does not block initObservability(). Cloud mode skips this check.
|
|
100
|
+
if (!isCloudMode) {
|
|
101
|
+
void fetch(`${endpoint}/v1/traces`, {
|
|
102
|
+
method: "POST",
|
|
103
|
+
body: "[]",
|
|
104
|
+
signal: AbortSignal.timeout(2000),
|
|
105
|
+
}).catch(() => {
|
|
106
|
+
console.warn(`toad-eye: cannot reach OTel Collector at ${endpoint} — no telemetry will be exported. Is the stack running? Run: npx toad-eye up`);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
75
109
|
if (isCloudMode) {
|
|
76
110
|
const masked = config.apiKey
|
|
77
111
|
? config.apiKey.slice(0, 8) + "..." + config.apiKey.slice(-4)
|
|
@@ -82,7 +116,13 @@ export function initObservability(config) {
|
|
|
82
116
|
budgetTracker = new BudgetTracker(config.budgets, config.onBudgetExceeded ?? "warn", config.downgradeCallback);
|
|
83
117
|
}
|
|
84
118
|
if (config.instrument?.length) {
|
|
85
|
-
|
|
119
|
+
// Filter out 'ai' — it uses SpanProcessor, not monkey-patching
|
|
120
|
+
const patchProviders = config.instrument.filter((i) => i !== "ai");
|
|
121
|
+
if (patchProviders.length > 0) {
|
|
122
|
+
// enableAll is async (lazy-loads provider modules on first call).
|
|
123
|
+
// Fire-and-forget: patching completes before any async SDK call.
|
|
124
|
+
void enableAll(patchProviders);
|
|
125
|
+
}
|
|
86
126
|
}
|
|
87
127
|
}
|
|
88
128
|
export async function shutdown() {
|
|
@@ -93,4 +133,6 @@ export async function shutdown() {
|
|
|
93
133
|
sdk = null;
|
|
94
134
|
currentConfig = null;
|
|
95
135
|
budgetTracker = null;
|
|
136
|
+
resetMetrics();
|
|
137
|
+
resetCustomPricing();
|
|
96
138
|
}
|
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
|
}
|