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.
Files changed (47) hide show
  1. package/README.md +166 -152
  2. package/dist/agent.d.ts +19 -28
  3. package/dist/agent.js +46 -27
  4. package/dist/alerts/channels.js +24 -6
  5. package/dist/alerts/index.js +46 -2
  6. package/dist/alerts/manager.d.ts +1 -0
  7. package/dist/alerts/manager.js +36 -21
  8. package/dist/budget/tracker.d.ts +16 -6
  9. package/dist/budget/tracker.js +38 -17
  10. package/dist/budget/types.d.ts +2 -0
  11. package/dist/cli.d.ts +8 -1
  12. package/dist/cli.js +106 -54
  13. package/dist/core/metrics.d.ts +4 -0
  14. package/dist/core/metrics.js +19 -0
  15. package/dist/core/pricing.d.ts +2 -0
  16. package/dist/core/pricing.js +4 -0
  17. package/dist/core/spans.d.ts +5 -3
  18. package/dist/core/spans.js +79 -20
  19. package/dist/core/tracer.d.ts +0 -3
  20. package/dist/core/tracer.js +40 -7
  21. package/dist/drift/baseline.js +7 -1
  22. package/dist/drift/monitor.js +6 -6
  23. package/dist/index.d.ts +7 -2
  24. package/dist/index.js +6 -1
  25. package/dist/instrumentations/anthropic.d.ts +1 -1
  26. package/dist/instrumentations/anthropic.js +2 -3
  27. package/dist/instrumentations/create.js +80 -19
  28. package/dist/instrumentations/gemini.d.ts +1 -1
  29. package/dist/instrumentations/gemini.js +6 -7
  30. package/dist/instrumentations/openai.d.ts +1 -1
  31. package/dist/instrumentations/openai.js +2 -3
  32. package/dist/instrumentations/registry.d.ts +3 -1
  33. package/dist/instrumentations/registry.js +28 -5
  34. package/dist/instrumentations/types.d.ts +4 -2
  35. package/dist/types/attributes.d.ts +3 -0
  36. package/dist/types/attributes.js +3 -0
  37. package/dist/types/config.d.ts +22 -2
  38. package/dist/types/index.d.ts +3 -3
  39. package/dist/types/metrics.d.ts +2 -0
  40. package/dist/types/metrics.js +3 -0
  41. package/dist/types/providers.d.ts +6 -1
  42. package/dist/types/spans.d.ts +11 -2
  43. package/dist/vercel.d.ts +53 -0
  44. package/dist/vercel.js +106 -0
  45. package/package.json +29 -3
  46. package/templates/grafana/dashboards/agent-workflow.json +207 -0
  47. package/templates/otel-collector.yml +25 -1
@@ -1,4 +1,10 @@
1
- import nodemailer from "nodemailer";
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 transporter = nodemailer.createTransport({
56
- host: config.host,
57
- port: config.port,
58
- auth: { user: config.user, pass: config.password },
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(", ");
@@ -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 config = yaml.parse(raw);
9
- const manager = new AlertManager(config);
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
  }
@@ -8,6 +8,7 @@ export declare class AlertManager {
8
8
  stop(): void;
9
9
  private isInCooldown;
10
10
  private evaluate;
11
+ private evaluateRule;
11
12
  private fireChannels;
12
13
  private postAnnotation;
13
14
  }
@@ -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 intervalMs = (this.config.evalIntervalSeconds ?? DEFAULT_EVAL_INTERVAL_SECONDS) * 1000;
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 ${intervalMs / 1000}s`);
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
- for (const rule of this.config.alerts) {
42
- if (this.isInCooldown(rule))
43
- continue;
44
- const result = await evaluateCondition(prometheusUrl, rule);
45
- if (!result?.triggered)
46
- continue;
47
- this.cooldowns.set(rule.name, Date.now());
48
- const firedAlert = {
49
- rule,
50
- value: result.value,
51
- threshold: result.threshold,
52
- topModels: result.topModels,
53
- firedAt: new Date(),
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 ?? {};
@@ -9,18 +9,28 @@ export declare class BudgetTracker {
9
9
  private resetIfNewDay;
10
10
  /**
11
11
  * Check budget BEFORE making an LLM call.
12
- * Returns modified provider/model if downgrade mode triggers,
13
- * throws ToadBudgetExceededError if block mode triggers,
14
- * returns null if no action needed.
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
- /** Record cost AFTER a successful LLM call. Returns exceeded info if budget was just crossed. */
21
- recordCost(cost: number, model: string, userId?: string): BudgetExceededInfo | null;
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). */
@@ -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
- * Returns modified provider/model if downgrade mode triggers,
36
- * throws ToadBudgetExceededError if block mode triggers,
37
- * returns null if no action needed.
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 (!exceeded)
43
- return null;
44
- if (this.mode === "block") {
45
- throw new ToadBudgetExceededError(exceeded);
46
- }
47
- if (this.mode === "downgrade" && this.downgrade) {
48
- return this.downgrade({ provider, model });
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
- // 'warn' mode — continue, warning emitted after call
60
+ // warn mode or no budget exceeded: reserve estimated cost and proceed
61
+ this.state.reservedCost += estimatedCost;
51
62
  return null;
52
63
  }
53
- /** Record cost AFTER a successful LLM call. Returns exceeded info if budget was just crossed. */
54
- recordCost(cost, model, userId) {
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 >= this.config.daily) {
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
  };
@@ -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
- export {};
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 { execSync, spawn } from "node:child_process";
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
- { name: "Grafana", url: "http://localhost:3100", login: "admin / admin" },
16
- { name: "Jaeger UI", url: "http://localhost:16686" },
17
- { name: "Prometheus", url: "http://localhost:9090" },
18
- { name: "OTel Collector", url: "http://localhost:4318" },
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
- if (existsSync(dest)) {
39
- console.log(`\u2705 ${INFRA_DIR}/ already exists, skipping.`);
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
- console.log(`\u2705 Created ${INFRA_DIR}/ with observability stack config.`);
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("\u{1f438} Starting observability stack...");
58
- execSync(`docker compose -f ${composeFile} up -d`, { stdio: "inherit" });
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
- execSync(`docker compose -f ${composeFile} down`, { stdio: "inherit" });
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 = execSync(`docker compose -f ${composeFile} ps --format json`, {
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.find((c) => svc.name.toLowerCase().includes(c.Service.toLowerCase()));
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("\u{1f438}\u{1f441}\u{fe0f} toad-eye demo — sending mock LLM traffic");
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
- \u{1f438} toad-eye CLI — observability stack for LLM services
234
+ 🐸 toad-eye CLI — observability stack for LLM services
191
235
 
192
236
  Commands:
193
- init Copy observability configs into your project
194
- up Start the stack (OTel Collector + Prometheus + Jaeger + Grafana)
195
- down Stop the stack
196
- status Show running services and URLs
197
- demo Send mock LLM traffic to see data in Grafana
198
- export-trace Export a Jaeger trace to toad-eval YAML
199
- help Show this message
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
- const command = process.argv[2];
203
- switch (command) {
204
- case "init":
205
- init();
206
- break;
207
- case "up":
208
- up();
209
- break;
210
- case "down":
211
- down();
212
- break;
213
- case "status":
214
- status();
215
- break;
216
- case "demo":
217
- demo();
218
- break;
219
- case "export-trace":
220
- exportTraceCommand();
221
- break;
222
- case "help":
223
- case "--help":
224
- case "-h":
225
- case undefined:
226
- help();
227
- break;
228
- default:
229
- console.error(`Unknown command: ${command}`);
230
- help();
231
- process.exit(1);
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
  }
@@ -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;
@@ -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 {
@@ -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
+ }
@@ -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.
@@ -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.