toad-eye 2.1.1 → 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 +5 -0
- package/dist/core/metrics.js +30 -0
- package/dist/core/pricing.d.ts +2 -0
- package/dist/core/pricing.js +6 -0
- package/dist/core/spans.d.ts +5 -3
- package/dist/core/spans.js +84 -19
- package/dist/core/tracer.d.ts +0 -3
- package/dist/core/tracer.js +44 -8
- package/dist/drift/baseline.js +7 -1
- package/dist/drift/monitor.js +6 -2
- package/dist/drift/openai-embeddings.js +5 -1
- package/dist/export.js +1 -1
- package/dist/guard.d.ts +6 -8
- package/dist/guard.js +19 -21
- 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 +12 -20
- package/dist/instrumentations/create.d.ts +0 -4
- package/dist/instrumentations/create.js +136 -44
- package/dist/instrumentations/gemini.d.ts +1 -1
- package/dist/instrumentations/gemini.js +39 -7
- package/dist/instrumentations/openai.d.ts +1 -1
- package/dist/instrumentations/openai.js +10 -19
- package/dist/instrumentations/registry.d.ts +3 -1
- package/dist/instrumentations/registry.js +28 -5
- package/dist/instrumentations/types.d.ts +12 -8
- 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 +3 -0
- package/dist/types/metrics.js +5 -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/grafana/dashboards/provider-health.json +191 -0
- package/templates/otel-collector.yml +25 -1
package/dist/alerts/channels.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
// Cache nodemailer transporters by connection identity to avoid creating a new
|
|
2
|
+
// SMTP connection for every alert (frequent alerts would exhaust connection pools).
|
|
3
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
4
|
+
const smtpTransporterCache = new Map();
|
|
5
|
+
function smtpCacheKey(config) {
|
|
6
|
+
return `${config.host}:${config.port}:${config.user}`;
|
|
7
|
+
}
|
|
2
8
|
function formatMessage(alert) {
|
|
3
9
|
const topModelsText = alert.topModels.length > 0
|
|
4
10
|
? `\nTop models by spend:\n${alert.topModels.map((m) => ` • ${m.model}: $${m.value.toFixed(4)}`).join("\n")}`
|
|
@@ -52,11 +58,23 @@ async function sendWebhook(config, alert) {
|
|
|
52
58
|
throw new Error(`Webhook delivery failed: ${res.status}`);
|
|
53
59
|
}
|
|
54
60
|
async function sendEmail(config, alert) {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
const key = smtpCacheKey(config);
|
|
62
|
+
let transporter = smtpTransporterCache.get(key);
|
|
63
|
+
if (!transporter) {
|
|
64
|
+
let createTransport;
|
|
65
|
+
try {
|
|
66
|
+
({ createTransport } = await import("nodemailer"));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new Error("toad-eye: email alerts require nodemailer — install it: npm install nodemailer");
|
|
70
|
+
}
|
|
71
|
+
transporter = createTransport({
|
|
72
|
+
host: config.host,
|
|
73
|
+
port: config.port,
|
|
74
|
+
auth: { user: config.user, pass: config.password },
|
|
75
|
+
});
|
|
76
|
+
smtpTransporterCache.set(key, transporter);
|
|
77
|
+
}
|
|
60
78
|
const to = typeof config.to === "string"
|
|
61
79
|
? config.to
|
|
62
80
|
: Array.from(config.to).join(", ");
|
package/dist/alerts/index.js
CHANGED
|
@@ -3,10 +3,54 @@ export { parseCondition } from "./conditions.js";
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import yaml from "yaml";
|
|
5
5
|
import { AlertManager } from "./manager.js";
|
|
6
|
+
/** Resolve ${ENV_VAR} references in string values throughout the config. */
|
|
7
|
+
function resolveEnvVars(value) {
|
|
8
|
+
return value.replace(/\$\{([^}]+)\}/g, (_, name) => {
|
|
9
|
+
return process.env[name] ?? `\${${name}}`;
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
function interpolateConfig(obj) {
|
|
13
|
+
if (typeof obj === "string")
|
|
14
|
+
return resolveEnvVars(obj);
|
|
15
|
+
if (Array.isArray(obj))
|
|
16
|
+
return obj.map(interpolateConfig);
|
|
17
|
+
if (obj !== null && typeof obj === "object") {
|
|
18
|
+
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [
|
|
19
|
+
k,
|
|
20
|
+
interpolateConfig(v),
|
|
21
|
+
]));
|
|
22
|
+
}
|
|
23
|
+
return obj;
|
|
24
|
+
}
|
|
25
|
+
function validateConfig(config) {
|
|
26
|
+
if (typeof config !== "object" || config === null) {
|
|
27
|
+
throw new Error("toad-eye: alerts config must be an object");
|
|
28
|
+
}
|
|
29
|
+
const c = config;
|
|
30
|
+
if (!Array.isArray(c["alerts"])) {
|
|
31
|
+
throw new Error('toad-eye: alerts config missing required "alerts" array');
|
|
32
|
+
}
|
|
33
|
+
for (const rule of c["alerts"]) {
|
|
34
|
+
if (typeof rule !== "object" || rule === null) {
|
|
35
|
+
throw new Error("toad-eye: each alert rule must be an object");
|
|
36
|
+
}
|
|
37
|
+
const r = rule;
|
|
38
|
+
for (const field of ["name", "metric", "condition"]) {
|
|
39
|
+
if (typeof r[field] !== "string" || !r[field]) {
|
|
40
|
+
throw new Error(`toad-eye: alert rule missing required string field "${field}"`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!Array.isArray(r["channels"]) || r["channels"].length === 0) {
|
|
44
|
+
throw new Error(`toad-eye: alert rule "${String(r["name"])}" must have at least one channel`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
6
48
|
export function startAlertsFromFile(configPath) {
|
|
7
49
|
const raw = readFileSync(configPath, "utf-8");
|
|
8
|
-
const
|
|
9
|
-
const
|
|
50
|
+
const parsed = yaml.parse(raw);
|
|
51
|
+
const interpolated = interpolateConfig(parsed);
|
|
52
|
+
validateConfig(interpolated);
|
|
53
|
+
const manager = new AlertManager(interpolated);
|
|
10
54
|
manager.start();
|
|
11
55
|
return manager;
|
|
12
56
|
}
|
package/dist/alerts/manager.d.ts
CHANGED
package/dist/alerts/manager.js
CHANGED
|
@@ -5,6 +5,7 @@ const DEFAULT_PROMETHEUS_URL = "http://localhost:9090";
|
|
|
5
5
|
const DEFAULT_GRAFANA_URL = "http://localhost:3100";
|
|
6
6
|
const DEFAULT_EVAL_INTERVAL_SECONDS = 60;
|
|
7
7
|
const DEFAULT_COOLDOWN_MINUTES = 30;
|
|
8
|
+
const MIN_EVAL_INTERVAL_SECONDS = 10;
|
|
8
9
|
export class AlertManager {
|
|
9
10
|
config;
|
|
10
11
|
cooldowns = new Map();
|
|
@@ -13,10 +14,15 @@ export class AlertManager {
|
|
|
13
14
|
this.config = config;
|
|
14
15
|
}
|
|
15
16
|
start() {
|
|
16
|
-
const
|
|
17
|
+
const configured = this.config.evalIntervalSeconds ?? DEFAULT_EVAL_INTERVAL_SECONDS;
|
|
18
|
+
const clampedSeconds = Math.max(configured, MIN_EVAL_INTERVAL_SECONDS);
|
|
19
|
+
if (configured < MIN_EVAL_INTERVAL_SECONDS) {
|
|
20
|
+
console.warn(`[toad-eye alerts] evalIntervalSeconds ${configured} is below minimum ${MIN_EVAL_INTERVAL_SECONDS}s — using ${clampedSeconds}s`);
|
|
21
|
+
}
|
|
22
|
+
const intervalMs = clampedSeconds * 1000;
|
|
17
23
|
this.intervalId = setInterval(() => void this.evaluate(), intervalMs);
|
|
18
24
|
void this.evaluate();
|
|
19
|
-
console.log(`[toad-eye alerts] Started — ${this.config.alerts.length} rule(s), eval every ${
|
|
25
|
+
console.log(`[toad-eye alerts] Started — ${this.config.alerts.length} rule(s), eval every ${clampedSeconds}s`);
|
|
20
26
|
}
|
|
21
27
|
stop() {
|
|
22
28
|
if (this.intervalId !== undefined) {
|
|
@@ -38,26 +44,35 @@ export class AlertManager {
|
|
|
38
44
|
async evaluate() {
|
|
39
45
|
const prometheusUrl = this.config.prometheusUrl ?? DEFAULT_PROMETHEUS_URL;
|
|
40
46
|
const grafanaUrl = this.config.grafanaUrl ?? DEFAULT_GRAFANA_URL;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
};
|
|
55
|
-
console.log(`[toad-eye alerts] 🚨 "${rule.name}" fired — value=${result.value.toFixed(4)}, threshold=${result.threshold}`);
|
|
56
|
-
await Promise.allSettled([
|
|
57
|
-
this.fireChannels(firedAlert),
|
|
58
|
-
this.postAnnotation(grafanaUrl, firedAlert.rule.name, firedAlert.value, firedAlert.threshold),
|
|
59
|
-
]);
|
|
47
|
+
// Evaluate all rules in parallel — each rule is independent
|
|
48
|
+
await Promise.allSettled(this.config.alerts.map((rule) => this.evaluateRule(prometheusUrl, grafanaUrl, rule)));
|
|
49
|
+
}
|
|
50
|
+
async evaluateRule(prometheusUrl, grafanaUrl, rule) {
|
|
51
|
+
if (this.isInCooldown(rule))
|
|
52
|
+
return;
|
|
53
|
+
let result;
|
|
54
|
+
try {
|
|
55
|
+
result = await evaluateCondition(prometheusUrl, rule);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
console.error(`[toad-eye alerts] Failed to evaluate rule "${rule.name}":`, err);
|
|
59
|
+
return;
|
|
60
60
|
}
|
|
61
|
+
if (!result?.triggered)
|
|
62
|
+
return;
|
|
63
|
+
this.cooldowns.set(rule.name, Date.now());
|
|
64
|
+
const firedAlert = {
|
|
65
|
+
rule,
|
|
66
|
+
value: result.value,
|
|
67
|
+
threshold: result.threshold,
|
|
68
|
+
topModels: result.topModels,
|
|
69
|
+
firedAt: new Date(),
|
|
70
|
+
};
|
|
71
|
+
console.log(`[toad-eye alerts] 🚨 "${rule.name}" fired — value=${result.value.toFixed(4)}, threshold=${result.threshold}`);
|
|
72
|
+
await Promise.allSettled([
|
|
73
|
+
this.fireChannels(firedAlert),
|
|
74
|
+
this.postAnnotation(grafanaUrl, firedAlert.rule.name, firedAlert.value, firedAlert.threshold),
|
|
75
|
+
]);
|
|
61
76
|
}
|
|
62
77
|
async fireChannels(alert) {
|
|
63
78
|
const channels = this.config.channels ?? {};
|
package/dist/budget/tracker.d.ts
CHANGED
|
@@ -9,18 +9,28 @@ export declare class BudgetTracker {
|
|
|
9
9
|
private resetIfNewDay;
|
|
10
10
|
/**
|
|
11
11
|
* Check budget BEFORE making an LLM call.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* returns null
|
|
12
|
+
* - block mode: throws ToadBudgetExceededError if budget is exceeded.
|
|
13
|
+
* - downgrade mode: returns modified provider/model + budget type that triggered it.
|
|
14
|
+
* - warn mode: returns null (warning emitted after call via recordCost).
|
|
15
|
+
*
|
|
16
|
+
* When estimatedCost is provided, it is added to reservedCost so concurrent
|
|
17
|
+
* in-flight requests are counted against the budget before their actual cost is known.
|
|
18
|
+
* Call recordCost with the same estimatedCost to release the reservation.
|
|
15
19
|
*/
|
|
16
|
-
checkBefore(provider: string, model: string, userId?: string): {
|
|
20
|
+
checkBefore(provider: string, model: string, userId?: string, estimatedCost?: number): {
|
|
17
21
|
provider: string;
|
|
18
22
|
model: string;
|
|
23
|
+
budget: BudgetExceededInfo["budget"];
|
|
19
24
|
} | null;
|
|
20
|
-
/**
|
|
21
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Record cost AFTER a successful LLM call. Returns exceeded info if budget was just crossed.
|
|
27
|
+
* Pass the same estimatedCost used in checkBefore() to release the reservation atomically.
|
|
28
|
+
*/
|
|
29
|
+
recordCost(cost: number, model: string, userId?: string, reservedAmount?: number): BudgetExceededInfo | null;
|
|
22
30
|
/** Find which budget (if any) is exceeded. */
|
|
23
31
|
private findExceeded;
|
|
32
|
+
/** Release a cost reservation when an LLM call fails (no cost was incurred). */
|
|
33
|
+
releaseReservation(reservedAmount: number): void;
|
|
24
34
|
/** Get current budget usage as percentage (0-100+). */
|
|
25
35
|
getUsagePercent(): number;
|
|
26
36
|
/** Get current state snapshot (for persistence or debugging). */
|
package/dist/budget/tracker.js
CHANGED
|
@@ -14,6 +14,7 @@ export class BudgetTracker {
|
|
|
14
14
|
this.state = {
|
|
15
15
|
date: todayUTC(),
|
|
16
16
|
totalCost: 0,
|
|
17
|
+
reservedCost: 0,
|
|
17
18
|
perUser: new Map(),
|
|
18
19
|
perModel: new Map(),
|
|
19
20
|
};
|
|
@@ -25,6 +26,7 @@ export class BudgetTracker {
|
|
|
25
26
|
this.state = {
|
|
26
27
|
date: today,
|
|
27
28
|
totalCost: 0,
|
|
29
|
+
reservedCost: 0,
|
|
28
30
|
perUser: new Map(),
|
|
29
31
|
perModel: new Map(),
|
|
30
32
|
};
|
|
@@ -32,27 +34,40 @@ export class BudgetTracker {
|
|
|
32
34
|
}
|
|
33
35
|
/**
|
|
34
36
|
* Check budget BEFORE making an LLM call.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* returns null
|
|
37
|
+
* - block mode: throws ToadBudgetExceededError if budget is exceeded.
|
|
38
|
+
* - downgrade mode: returns modified provider/model + budget type that triggered it.
|
|
39
|
+
* - warn mode: returns null (warning emitted after call via recordCost).
|
|
40
|
+
*
|
|
41
|
+
* When estimatedCost is provided, it is added to reservedCost so concurrent
|
|
42
|
+
* in-flight requests are counted against the budget before their actual cost is known.
|
|
43
|
+
* Call recordCost with the same estimatedCost to release the reservation.
|
|
38
44
|
*/
|
|
39
|
-
checkBefore(provider, model, userId) {
|
|
45
|
+
checkBefore(provider, model, userId, estimatedCost = 0) {
|
|
40
46
|
this.resetIfNewDay();
|
|
41
|
-
const exceeded = this.findExceeded(model, userId);
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
const exceeded = this.findExceeded(model, userId, estimatedCost);
|
|
48
|
+
if (exceeded) {
|
|
49
|
+
if (this.mode === "block") {
|
|
50
|
+
throw new ToadBudgetExceededError(exceeded);
|
|
51
|
+
}
|
|
52
|
+
if (this.mode === "downgrade" && this.downgrade) {
|
|
53
|
+
this.state.reservedCost += estimatedCost;
|
|
54
|
+
return {
|
|
55
|
+
...this.downgrade({ provider, model }),
|
|
56
|
+
budget: exceeded.budget,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
49
59
|
}
|
|
50
|
-
//
|
|
60
|
+
// warn mode or no budget exceeded: reserve estimated cost and proceed
|
|
61
|
+
this.state.reservedCost += estimatedCost;
|
|
51
62
|
return null;
|
|
52
63
|
}
|
|
53
|
-
/**
|
|
54
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Record cost AFTER a successful LLM call. Returns exceeded info if budget was just crossed.
|
|
66
|
+
* Pass the same estimatedCost used in checkBefore() to release the reservation atomically.
|
|
67
|
+
*/
|
|
68
|
+
recordCost(cost, model, userId, reservedAmount = 0) {
|
|
55
69
|
this.resetIfNewDay();
|
|
70
|
+
this.state.reservedCost = Math.max(0, this.state.reservedCost - reservedAmount);
|
|
56
71
|
this.state.totalCost += cost;
|
|
57
72
|
this.state.perModel.set(model, (this.state.perModel.get(model) ?? 0) + cost);
|
|
58
73
|
if (userId) {
|
|
@@ -61,9 +76,10 @@ export class BudgetTracker {
|
|
|
61
76
|
return this.findExceeded(model, userId);
|
|
62
77
|
}
|
|
63
78
|
/** Find which budget (if any) is exceeded. */
|
|
64
|
-
findExceeded(model, userId) {
|
|
79
|
+
findExceeded(model, userId, additionalCost = 0) {
|
|
65
80
|
if (this.config.daily !== undefined &&
|
|
66
|
-
this.state.totalCost
|
|
81
|
+
this.state.totalCost + this.state.reservedCost + additionalCost >=
|
|
82
|
+
this.config.daily) {
|
|
67
83
|
return {
|
|
68
84
|
budget: "daily",
|
|
69
85
|
limit: this.config.daily,
|
|
@@ -95,6 +111,10 @@ export class BudgetTracker {
|
|
|
95
111
|
}
|
|
96
112
|
return null;
|
|
97
113
|
}
|
|
114
|
+
/** Release a cost reservation when an LLM call fails (no cost was incurred). */
|
|
115
|
+
releaseReservation(reservedAmount) {
|
|
116
|
+
this.state.reservedCost = Math.max(0, this.state.reservedCost - reservedAmount);
|
|
117
|
+
}
|
|
98
118
|
/** Get current budget usage as percentage (0-100+). */
|
|
99
119
|
getUsagePercent() {
|
|
100
120
|
if (this.config.daily === undefined || this.config.daily === 0)
|
|
@@ -114,6 +134,7 @@ export class BudgetTracker {
|
|
|
114
134
|
this.state = {
|
|
115
135
|
date: saved.date,
|
|
116
136
|
totalCost: saved.totalCost,
|
|
137
|
+
reservedCost: 0,
|
|
117
138
|
perUser: new Map(Object.entries(saved.perUser)),
|
|
118
139
|
perModel: new Map(Object.entries(saved.perModel)),
|
|
119
140
|
};
|
package/dist/budget/types.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export type DowngradeCallback = (original: {
|
|
|
21
21
|
export interface BudgetState {
|
|
22
22
|
date: string;
|
|
23
23
|
totalCost: number;
|
|
24
|
+
/** Sum of estimated costs for in-flight requests (released when recordCost is called). */
|
|
25
|
+
reservedCost: number;
|
|
24
26
|
perUser: Map<string, number>;
|
|
25
27
|
perModel: Map<string, number>;
|
|
26
28
|
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,2 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
/** Find the docker container matching a service by exact service name. Exported for testing. */
|
|
3
|
+
export declare function findContainerByService(containers: ReadonlyArray<{
|
|
4
|
+
Service: string;
|
|
5
|
+
State: string;
|
|
6
|
+
}>, serviceKey: string): {
|
|
7
|
+
Service: string;
|
|
8
|
+
State: string;
|
|
9
|
+
} | undefined;
|
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;
|
|
@@ -9,6 +11,9 @@ export declare function recordAgentToolUsage(toolName: string): void;
|
|
|
9
11
|
export declare function recordGuardEvaluation(ruleName: string): void;
|
|
10
12
|
export declare function recordGuardWouldBlock(ruleName: string): void;
|
|
11
13
|
export declare function recordSemanticDrift(drift: number, provider: string, model: string): void;
|
|
14
|
+
export declare function recordTimeToFirstToken(ms: number, provider: string, model: string): void;
|
|
12
15
|
export declare function recordBudgetExceeded(budgetType: string): void;
|
|
13
16
|
export declare function recordBudgetBlocked(budgetType: string): void;
|
|
14
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;
|