toad-eye 1.1.0 → 2.1.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 (90) hide show
  1. package/README.md +132 -87
  2. package/dist/agent.d.ts +41 -0
  3. package/dist/agent.js +91 -0
  4. package/dist/alerts/channels.d.ts +2 -0
  5. package/dist/alerts/channels.js +81 -0
  6. package/dist/alerts/conditions.d.ts +29 -0
  7. package/dist/alerts/conditions.js +147 -0
  8. package/dist/alerts/grafana.d.ts +5 -0
  9. package/dist/alerts/grafana.js +19 -0
  10. package/dist/alerts/index.d.ts +5 -0
  11. package/dist/alerts/index.js +12 -0
  12. package/dist/alerts/manager.d.ts +13 -0
  13. package/dist/alerts/manager.js +87 -0
  14. package/dist/alerts/types.d.ts +46 -0
  15. package/dist/alerts/types.js +1 -0
  16. package/dist/budget/error.d.ts +10 -0
  17. package/dist/budget/error.js +22 -0
  18. package/dist/budget/index.d.ts +3 -0
  19. package/dist/budget/index.js +2 -0
  20. package/dist/budget/tracker.d.ts +35 -0
  21. package/dist/budget/tracker.js +121 -0
  22. package/dist/budget/types.d.ts +34 -0
  23. package/dist/budget/types.js +1 -0
  24. package/dist/cli.js +127 -18
  25. package/dist/core/metrics.d.ts +14 -0
  26. package/dist/core/metrics.js +119 -0
  27. package/dist/core/pricing.d.ts +24 -0
  28. package/dist/core/pricing.js +52 -0
  29. package/dist/{spans.d.ts → core/spans.d.ts} +3 -1
  30. package/dist/core/spans.js +143 -0
  31. package/dist/core/tracer.d.ts +9 -0
  32. package/dist/core/tracer.js +93 -0
  33. package/dist/drift/baseline.d.ts +16 -0
  34. package/dist/drift/baseline.js +16 -0
  35. package/dist/drift/cosine.d.ts +13 -0
  36. package/dist/drift/cosine.js +39 -0
  37. package/dist/drift/embedding-provider.d.ts +21 -0
  38. package/dist/drift/embedding-provider.js +8 -0
  39. package/dist/drift/index.d.ts +9 -0
  40. package/dist/drift/index.js +8 -0
  41. package/dist/drift/monitor.d.ts +29 -0
  42. package/dist/drift/monitor.js +47 -0
  43. package/dist/drift/openai-embeddings.d.ts +6 -0
  44. package/dist/drift/openai-embeddings.js +26 -0
  45. package/dist/export.d.ts +46 -0
  46. package/dist/export.js +108 -0
  47. package/dist/guard.d.ts +26 -0
  48. package/dist/guard.js +45 -0
  49. package/dist/index.d.ts +17 -5
  50. package/dist/index.js +10 -3
  51. package/dist/instrumentations/anthropic.d.ts +1 -0
  52. package/dist/instrumentations/anthropic.js +73 -0
  53. package/dist/instrumentations/create.d.ts +11 -0
  54. package/dist/instrumentations/create.js +165 -0
  55. package/dist/instrumentations/gemini.d.ts +1 -0
  56. package/dist/instrumentations/gemini.js +48 -0
  57. package/dist/instrumentations/openai.d.ts +1 -0
  58. package/dist/instrumentations/openai.js +94 -0
  59. package/dist/instrumentations/registry.d.ts +5 -0
  60. package/dist/instrumentations/registry.js +31 -0
  61. package/dist/instrumentations/types.d.ts +37 -0
  62. package/dist/instrumentations/types.js +1 -0
  63. package/dist/types/attributes.d.ts +48 -0
  64. package/dist/types/attributes.js +53 -0
  65. package/dist/types/config.d.ts +34 -0
  66. package/dist/types/config.js +1 -0
  67. package/dist/types/index.d.ts +17 -0
  68. package/dist/types/index.js +13 -0
  69. package/dist/types/metrics.d.ts +33 -0
  70. package/dist/types/metrics.js +35 -0
  71. package/dist/types/providers.d.ts +5 -0
  72. package/dist/types/providers.js +1 -0
  73. package/dist/types/spans.d.ts +40 -0
  74. package/dist/types/spans.js +1 -0
  75. package/package.json +38 -4
  76. package/templates/alerts.example.yml +54 -0
  77. package/templates/docker-compose.yml +1 -2
  78. package/templates/grafana/dashboards/cost-breakdown.json +208 -0
  79. package/templates/grafana/dashboards/error-drilldown.json +188 -0
  80. package/templates/grafana/dashboards/finops-attribution.json +278 -0
  81. package/templates/grafana/dashboards/latency-analysis.json +134 -0
  82. package/templates/grafana/dashboards/llm-overview.json +161 -30
  83. package/templates/grafana/dashboards/model-comparison.json +143 -0
  84. package/dist/metrics.d.ts +0 -6
  85. package/dist/metrics.js +0 -46
  86. package/dist/spans.js +0 -61
  87. package/dist/tracer.d.ts +0 -4
  88. package/dist/tracer.js +0 -46
  89. package/dist/types.d.ts +0 -69
  90. package/dist/types.js +0 -32
package/README.md CHANGED
@@ -1,126 +1,171 @@
1
1
  # toad-eye 🐸👁️
2
2
 
3
- ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)
4
- ![OpenTelemetry](https://img.shields.io/badge/OpenTelemetry-000000?logo=opentelemetry&logoColor=white)
5
- ![Hono](https://img.shields.io/badge/Hono-E36002?logo=hono&logoColor=white)
6
- ![Prometheus](https://img.shields.io/badge/Prometheus-E6522C?logo=prometheus&logoColor=white)
7
- ![Grafana](https://img.shields.io/badge/Grafana-F46800?logo=grafana&logoColor=white)
8
- ![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white)
9
- ![npm](https://img.shields.io/npm/v/toad-eye?color=CB3837&logo=npm&logoColor=white)
10
- ![CI](https://github.com/vola-trebla/toad-eye/actions/workflows/ci.yml/badge.svg)
11
- ![License](https://img.shields.io/badge/License-ISC-blue)
3
+ [![npm version](https://img.shields.io/npm/v/toad-eye?color=CB3837&logo=npm&logoColor=white)](https://www.npmjs.com/package/toad-eye)
4
+ [![npm downloads](https://img.shields.io/npm/dm/toad-eye?color=CB3837&logo=npm&logoColor=white)](https://www.npmjs.com/package/toad-eye)
5
+ [![CI](https://github.com/vola-trebla/toad-eye/actions/workflows/ci.yml/badge.svg)](https://github.com/vola-trebla/toad-eye/actions)
6
+ [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](https://opensource.org/licenses/ISC)
12
7
 
13
- OpenTelemetry-based observability toolkit for LLM systems.
8
+ **Full observability for LLM services — traces, metrics, and Grafana dashboards in one line of code.**
14
9
 
15
- One-line instrumentation for any LLM service — get traces, metrics, and Grafana dashboards out of the box.
10
+ > LLM APIs are black boxes. You don't know what they cost, how slow they are, or why they fail.
11
+ > toad-eye gives you complete visibility with zero manual instrumentation.
16
12
 
17
- ## Why toad-eye?
13
+ <!-- TODO: record GIF — terminal: npx toad-eye init → up → demo → Grafana with live data -->
14
+ <!-- ![toad-eye demo](https://raw.githubusercontent.com/vola-trebla/toad-eye/main/demo/toad-eye-demo.gif) -->
18
15
 
19
- LLM APIs are black boxes — you don't know what they cost, how slow they are, or why they fail. toad-eye gives you full visibility with one line of code.
16
+ ---
20
17
 
21
- ## Install
18
+ ## Quick Start
22
19
 
23
20
  ```bash
24
21
  npm install toad-eye
22
+ npx toad-eye init # scaffold observability stack configs
23
+ npx toad-eye up # start OTel Collector + Prometheus + Jaeger + Grafana
25
24
  ```
26
25
 
27
- ## Architecture
26
+ ```typescript
27
+ import { initObservability } from "toad-eye";
28
28
 
29
- ```
30
- Your LLM service
31
- │
32
- ▼
33
- toad-eye
34
- │
35
- ▼ OTLP/HTTP
36
- OTel Collector
37
- ├──▶ Prometheus ──▶ Grafana
38
- └──▶ Jaeger
29
+ initObservability({
30
+ serviceName: "my-app",
31
+ instrument: ["anthropic", "openai"],
32
+ });
33
+ // Every LLM call is now automatically traced. Open http://localhost:3100
39
34
  ```
40
35
 
41
- ## Quick start
36
+ That's it. **No wrappers. No manual spans.** All your Anthropic and OpenAI calls are traced automatically.
42
37
 
43
- ```bash
44
- npm install
45
- cd infra && docker compose up -d # start observability stack
46
- npm run demo # start mock LLM service
47
- npm run load --workspace=demo # send test traffic
38
+ ---
39
+
40
+ ## What you get
41
+
42
+ ![toad-eye Grafana dashboard](https://raw.githubusercontent.com/vola-trebla/toad-eye/main/demo/grafana-dashboard.png)
43
+
44
+ | Feature | Details |
45
+ | ------------------------ | --------------------------------------------------------------------------------- |
46
+ | **Auto-instrumentation** | Patches Anthropic, OpenAI, Gemini SDKs — no code changes needed |
47
+ | **5 Grafana dashboards** | Overview · Cost Breakdown · Latency Analysis · Model Comparison · Error Drilldown |
48
+ | **Cost tracking** | Per-request USD cost, hourly/daily totals, top models by spend |
49
+ | **Cost alerts** | Threshold alerts via Telegram, Slack, email, or webhook |
50
+ | **Distributed traces** | Full spans in Jaeger with prompts, completions, tokens |
51
+ | **One-command stack** | `npx toad-eye up` starts the entire observability stack |
52
+
53
+ ---
54
+
55
+ ## Observability stack
56
+
57
+ ```mermaid
58
+ flowchart LR
59
+ app["Your LLM App\n(toad-eye SDK)"]
60
+ col["OTel Collector\n:4318"]
61
+ prom["Prometheus\n:9090"]
62
+ jaeger["Jaeger\n:16686"]
63
+ grafana["Grafana\n:3100"]
64
+
65
+ app -->|OTLP/HTTP| col
66
+ col -->|metrics| prom
67
+ col -->|traces| jaeger
68
+ prom --> grafana
69
+ jaeger --> grafana
48
70
  ```
49
71
 
50
- | Service | URL |
51
- | -------------- | ----------------------------------------- |
52
- | Grafana | [localhost:3000](http://localhost:3000) |
53
- | Jaeger UI | [localhost:16686](http://localhost:16686) |
54
- | Prometheus | [localhost:9090](http://localhost:9090) |
55
- | OTel Collector | [localhost:4318](http://localhost:4318) |
72
+ | Service | URL |
73
+ | ---------- | ------------------------------------- |
74
+ | Grafana | http://localhost:3100 (admin / admin) |
75
+ | Jaeger | http://localhost:16686 |
76
+ | Prometheus | http://localhost:9090 |
56
77
 
57
- ## Usage
78
+ ---
58
79
 
59
- ```typescript
60
- import { initObservability, traceLLMCall } from "toad-eye";
80
+ ## Metrics
61
81
 
62
- // one-line init
63
- initObservability({
64
- serviceName: "my-llm-service",
65
- endpoint: "http://localhost:4318",
66
- });
82
+ | Metric | Type | Description |
83
+ | ---------------------- | --------- | ------------------------------------------- |
84
+ | `llm.request.duration` | Histogram | Request latency (ms), by provider + model |
85
+ | `llm.request.cost` | Histogram | Cost per request (USD), by provider + model |
86
+ | `llm.tokens` | Counter | Total tokens consumed (input + output) |
87
+ | `llm.requests` | Counter | Total requests made |
88
+ | `llm.errors` | Counter | Total failed requests |
89
+
90
+ ---
91
+
92
+ ## Advanced
93
+
94
+ ### Manual tracing
95
+
96
+ If you need to trace a provider not yet auto-instrumented:
97
+
98
+ ```typescript
99
+ import { traceLLMCall } from "toad-eye";
67
100
 
68
- // wrap any LLM call
69
101
  const result = await traceLLMCall(
70
- { provider: "anthropic", model: "claude-sonnet-4-20250514", prompt: "hello" },
71
- () => callYourLLM(),
102
+ { provider: "anthropic", model: "claude-sonnet-4-20250514", prompt: input, temperature: 0.7 },
103
+ () => client.messages.create({ ... }),
104
+ (res) => ({
105
+ completion: res.content[0].text,
106
+ inputTokens: res.usage.input_tokens,
107
+ outputTokens: res.usage.output_tokens,
108
+ cost: calcCost(res.usage),
109
+ }),
72
110
  );
73
111
  ```
74
112
 
75
- > **Privacy mode:** pass `recordContent: false` to `initObservability()` to stop recording prompt/completion text in spans. Useful in production when prompts contain sensitive data.
113
+ ### Privacy mode
76
114
 
77
- ## What it tracks
115
+ Stop recording prompt/completion content in spans — useful in production:
78
116
 
79
- ### Metrics
117
+ ```typescript
118
+ initObservability({ serviceName: "my-app", recordContent: false });
119
+ ```
80
120
 
81
- | Metric | Type | Description |
82
- | ---------------------- | --------- | -------------------------------------- |
83
- | `llm.request.duration` | Histogram | Request latency in milliseconds |
84
- | `llm.request.cost` | Histogram | Cost per request in USD |
85
- | `llm.tokens` | Counter | Total tokens consumed (input + output) |
86
- | `llm.requests` | Counter | Total requests made |
87
- | `llm.errors` | Counter | Total failed requests |
121
+ ### Cost alerts
88
122
 
89
- All metrics are labeled with `provider` and `model` for filtering and grouping.
123
+ Fire alerts when spend crosses a threshold:
90
124
 
91
- ### Span attributes
125
+ ```typescript
126
+ import { AlertManager } from "toad-eye";
127
+
128
+ const alerts = new AlertManager({
129
+ prometheusUrl: "http://localhost:9090",
130
+ channels: {
131
+ telegram: {
132
+ type: "telegram",
133
+ token: process.env.TG_TOKEN!,
134
+ chatId: process.env.TG_CHAT!,
135
+ },
136
+ },
137
+ alerts: [
138
+ {
139
+ name: "cost_spike",
140
+ metric: "llm.request.cost",
141
+ condition: "sum_1h > 10",
142
+ channels: ["telegram"],
143
+ },
144
+ ],
145
+ });
92
146
 
93
- | Attribute | Type | Description |
94
- | ------------------- | ------ | ------------------------------- |
95
- | `llm.provider` | string | `anthropic`, `gemini`, `openai` |
96
- | `llm.model` | string | Model identifier |
97
- | `llm.prompt` | string | Prompt sent to the LLM |
98
- | `llm.completion` | string | Response from the LLM |
99
- | `llm.input_tokens` | number | Tokens in the prompt |
100
- | `llm.output_tokens` | number | Tokens in the completion |
101
- | `llm.cost` | number | Cost in USD |
102
- | `llm.temperature` | number | Temperature parameter |
103
- | `llm.status` | string | `success` or `error` |
104
- | `llm.error` | string | Error message (if failed) |
147
+ alerts.start();
148
+ ```
105
149
 
106
- ## Grafana dashboard
150
+ Or configure via YAML — see [`infra/toad-eye/alerts.yml`](https://github.com/vola-trebla/toad-eye/blob/main/infra/toad-eye/alerts.yml).
107
151
 
108
- ![toad-eye Grafana dashboard](https://raw.githubusercontent.com/vola-trebla/toad-eye/main/demo/grafana-dashboard.png)
152
+ ### Custom OTLP endpoint
109
153
 
110
- ## Jaeger traces
154
+ ```typescript
155
+ initObservability({
156
+ serviceName: "my-app",
157
+ endpoint: "http://your-collector:4318",
158
+ });
159
+ ```
111
160
 
112
- ![toad-eye Jaeger traces](https://raw.githubusercontent.com/vola-trebla/toad-eye/main/demo/jaeger-traces.png)
161
+ ---
113
162
 
114
- ## Project structure
163
+ ## CLI reference
115
164
 
165
+ ```bash
166
+ npx toad-eye init # scaffold infra/toad-eye/ configs into your project
167
+ npx toad-eye up # docker compose up -d (OTel + Prometheus + Jaeger + Grafana)
168
+ npx toad-eye down # docker compose down
169
+ npx toad-eye status # show running services
170
+ npx toad-eye demo # start built-in demo with mock LLM traffic
116
171
  ```
117
- packages/instrumentation/ — NPM package (toad-eye)
118
- demo/ — mock LLM service for testing
119
- infra/ — docker-compose stack (OTel + Prometheus + Jaeger + Grafana)
120
- ```
121
-
122
- ## Tech stack
123
-
124
- - TypeScript, OpenTelemetry SDK 2.x, OTLP exporters
125
- - Hono (demo server)
126
- - Docker Compose (Prometheus, Jaeger, Grafana, OTel Collector)
@@ -0,0 +1,41 @@
1
+ import type { AgentStepInput } from "./types/index.js";
2
+ /**
3
+ * Record a single agent step as a child span (ReAct pattern: think → act → observe → answer).
4
+ *
5
+ * Creates a short-lived span under the current active context. Call this inside
6
+ * {@link traceAgentQuery} (preferred) or any other active span (e.g. traceLLMCall).
7
+ *
8
+ * - Respects `recordContent` config — content is omitted when recording is disabled.
9
+ * - For `act` steps with a `toolName`, automatically increments the `agent.tool_usage` metric.
10
+ */
11
+ declare function traceAgentStep(input: AgentStepInput): void;
12
+ /**
13
+ * Wrap an entire agent query in a parent span, producing structured ReAct traces.
14
+ *
15
+ * The callback receives a `step` function to record individual steps.
16
+ * When the callback completes, the wrapper automatically records the
17
+ * `agent.steps_per_query` histogram metric with the total step count.
18
+ *
19
+ * Span hierarchy in Jaeger:
20
+ * ```
21
+ * [parent] agent.query "Is anything dangerous near Earth?"
22
+ * ├── agent.step.think "I need to check asteroids"
23
+ * ├── agent.step.act tool=near-earth-asteroids
24
+ * ├── agent.step.observe "7 asteroids found"
25
+ * └── agent.step.answer "7 asteroids passing safely..."
26
+ * ```
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * const result = await traceAgentQuery("Is anything dangerous near Earth?", async (step) => {
31
+ * step({ type: "think", stepNumber: 1, content: "I need to check asteroids" });
32
+ * const data = await callTool("near-earth-asteroids");
33
+ * step({ type: "act", stepNumber: 2, toolName: "near-earth-asteroids" });
34
+ * step({ type: "observe", stepNumber: 3, content: `${data.length} asteroids found` });
35
+ * step({ type: "answer", stepNumber: 4, content: "7 asteroids passing safely..." });
36
+ * return { answer: "7 asteroids passing safely..." };
37
+ * });
38
+ * ```
39
+ */
40
+ export declare function traceAgentQuery<T>(query: string, fn: (step: (input: AgentStepInput) => void) => Promise<T>): Promise<T>;
41
+ export { traceAgentStep };
package/dist/agent.js ADDED
@@ -0,0 +1,91 @@
1
+ import { trace, SpanStatusCode } from "@opentelemetry/api";
2
+ import { GEN_AI_ATTRS, INSTRUMENTATION_NAME } from "./types/index.js";
3
+ import { recordAgentSteps, recordAgentToolUsage } from "./core/metrics.js";
4
+ import { getConfig } from "./core/tracer.js";
5
+ const tracer = trace.getTracer(INSTRUMENTATION_NAME);
6
+ /**
7
+ * Record a single agent step as a child span (ReAct pattern: think → act → observe → answer).
8
+ *
9
+ * Creates a short-lived span under the current active context. Call this inside
10
+ * {@link traceAgentQuery} (preferred) or any other active span (e.g. traceLLMCall).
11
+ *
12
+ * - Respects `recordContent` config — content is omitted when recording is disabled.
13
+ * - For `act` steps with a `toolName`, automatically increments the `agent.tool_usage` metric.
14
+ */
15
+ function traceAgentStep(input) {
16
+ const span = tracer.startSpan(`agent.step.${input.type}`);
17
+ const config = getConfig();
18
+ const recordContent = config?.recordContent !== false;
19
+ span.setAttributes({
20
+ [GEN_AI_ATTRS.AGENT_STEP_TYPE]: input.type,
21
+ [GEN_AI_ATTRS.AGENT_STEP_NUMBER]: input.stepNumber,
22
+ ...(input.toolName !== undefined && {
23
+ [GEN_AI_ATTRS.AGENT_TOOL_NAME]: input.toolName,
24
+ }),
25
+ ...(recordContent &&
26
+ input.content !== undefined && {
27
+ [GEN_AI_ATTRS.AGENT_STEP_CONTENT]: input.content,
28
+ }),
29
+ });
30
+ if (input.type === "act" && input.toolName !== undefined) {
31
+ recordAgentToolUsage(input.toolName);
32
+ }
33
+ span.setStatus({ code: SpanStatusCode.OK });
34
+ span.end();
35
+ }
36
+ /**
37
+ * Wrap an entire agent query in a parent span, producing structured ReAct traces.
38
+ *
39
+ * The callback receives a `step` function to record individual steps.
40
+ * When the callback completes, the wrapper automatically records the
41
+ * `agent.steps_per_query` histogram metric with the total step count.
42
+ *
43
+ * Span hierarchy in Jaeger:
44
+ * ```
45
+ * [parent] agent.query "Is anything dangerous near Earth?"
46
+ * ├── agent.step.think "I need to check asteroids"
47
+ * ├── agent.step.act tool=near-earth-asteroids
48
+ * ├── agent.step.observe "7 asteroids found"
49
+ * └── agent.step.answer "7 asteroids passing safely..."
50
+ * ```
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * const result = await traceAgentQuery("Is anything dangerous near Earth?", async (step) => {
55
+ * step({ type: "think", stepNumber: 1, content: "I need to check asteroids" });
56
+ * const data = await callTool("near-earth-asteroids");
57
+ * step({ type: "act", stepNumber: 2, toolName: "near-earth-asteroids" });
58
+ * step({ type: "observe", stepNumber: 3, content: `${data.length} asteroids found` });
59
+ * step({ type: "answer", stepNumber: 4, content: "7 asteroids passing safely..." });
60
+ * return { answer: "7 asteroids passing safely..." };
61
+ * });
62
+ * ```
63
+ */
64
+ export async function traceAgentQuery(query, fn) {
65
+ return tracer.startActiveSpan(`agent.query`, async (span) => {
66
+ const config = getConfig();
67
+ const recordContent = config?.recordContent !== false;
68
+ if (recordContent) {
69
+ span.setAttribute(GEN_AI_ATTRS.AGENT_STEP_CONTENT, query);
70
+ }
71
+ let stepCount = 0;
72
+ try {
73
+ const result = await fn((input) => {
74
+ stepCount++;
75
+ traceAgentStep(input);
76
+ });
77
+ span.setStatus({ code: SpanStatusCode.OK });
78
+ return result;
79
+ }
80
+ catch (error) {
81
+ const message = error instanceof Error ? error.message : String(error);
82
+ span.setStatus({ code: SpanStatusCode.ERROR, message });
83
+ throw error;
84
+ }
85
+ finally {
86
+ recordAgentSteps(stepCount);
87
+ span.end();
88
+ }
89
+ });
90
+ }
91
+ export { traceAgentStep };
@@ -0,0 +1,2 @@
1
+ import type { AlertChannelConfig, FiredAlert } from "./types.js";
2
+ export declare function sendToChannel(channel: AlertChannelConfig, alert: FiredAlert): Promise<void>;
@@ -0,0 +1,81 @@
1
+ import nodemailer from "nodemailer";
2
+ function formatMessage(alert) {
3
+ const topModelsText = alert.topModels.length > 0
4
+ ? `\nTop models by spend:\n${alert.topModels.map((m) => ` • ${m.model}: $${m.value.toFixed(4)}`).join("\n")}`
5
+ : "";
6
+ return [
7
+ `🚨 Alert: ${alert.rule.name}`,
8
+ `Metric: ${alert.rule.metric}`,
9
+ `Value: ${alert.value.toFixed(4)} (threshold: ${alert.threshold})`,
10
+ `Condition: ${alert.rule.condition}`,
11
+ topModelsText,
12
+ ]
13
+ .filter(Boolean)
14
+ .join("\n");
15
+ }
16
+ async function sendTelegram(config, alert) {
17
+ const res = await fetch(`https://api.telegram.org/bot${config.token}/sendMessage`, {
18
+ method: "POST",
19
+ headers: { "Content-Type": "application/json" },
20
+ body: JSON.stringify({
21
+ chat_id: config.chatId,
22
+ text: formatMessage(alert),
23
+ }),
24
+ });
25
+ if (!res.ok)
26
+ throw new Error(`Telegram send failed: ${res.status}`);
27
+ }
28
+ async function sendSlackWebhook(config, alert) {
29
+ const res = await fetch(config.url, {
30
+ method: "POST",
31
+ headers: { "Content-Type": "application/json" },
32
+ body: JSON.stringify({ text: formatMessage(alert) }),
33
+ });
34
+ if (!res.ok)
35
+ throw new Error(`Slack webhook failed: ${res.status}`);
36
+ }
37
+ async function sendWebhook(config, alert) {
38
+ const res = await fetch(config.url, {
39
+ method: "POST",
40
+ headers: { "Content-Type": "application/json", ...config.headers },
41
+ body: JSON.stringify({
42
+ alertName: alert.rule.name,
43
+ metric: alert.rule.metric,
44
+ value: alert.value,
45
+ threshold: alert.threshold,
46
+ condition: alert.rule.condition,
47
+ topModels: alert.topModels,
48
+ firedAt: alert.firedAt.toISOString(),
49
+ }),
50
+ });
51
+ if (!res.ok)
52
+ throw new Error(`Webhook delivery failed: ${res.status}`);
53
+ }
54
+ 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
+ });
60
+ const to = typeof config.to === "string"
61
+ ? config.to
62
+ : Array.from(config.to).join(", ");
63
+ await transporter.sendMail({
64
+ from: config.from,
65
+ to,
66
+ subject: `🚨 toad-eye alert: ${alert.rule.name}`,
67
+ text: formatMessage(alert),
68
+ });
69
+ }
70
+ export async function sendToChannel(channel, alert) {
71
+ switch (channel.type) {
72
+ case "telegram":
73
+ return sendTelegram(channel, alert);
74
+ case "slack_webhook":
75
+ return sendSlackWebhook(channel, alert);
76
+ case "webhook":
77
+ return sendWebhook(channel, alert);
78
+ case "email":
79
+ return sendEmail(channel, alert);
80
+ }
81
+ }
@@ -0,0 +1,29 @@
1
+ import type { AlertRule } from "./types.js";
2
+ type ConditionOperator = "sum" | "avg" | "rate" | "max" | "p95_pct" | "ratio";
3
+ type Comparator = ">" | ">=" | "<" | "<=";
4
+ export interface ParsedCondition {
5
+ readonly operator: ConditionOperator;
6
+ readonly window: string;
7
+ readonly baselineWindow?: string | undefined;
8
+ readonly comparator: Comparator;
9
+ readonly threshold: number;
10
+ }
11
+ /**
12
+ * Parse condition string into structured form.
13
+ *
14
+ * Standard: "sum_1h > 10", "rate_5m > 0.05"
15
+ * Baseline: "p95_pct_5m_7d > 50" — p95 over 5m increased >50% vs 7d baseline
16
+ * Ratio: "ratio_15m > 0.05" — errors/requests ratio over 15m
17
+ */
18
+ export declare function parseCondition(condition: string): ParsedCondition;
19
+ export interface EvalResult {
20
+ readonly triggered: boolean;
21
+ readonly value: number;
22
+ readonly threshold: number;
23
+ readonly topModels: Array<{
24
+ model: string;
25
+ value: number;
26
+ }>;
27
+ }
28
+ export declare function evaluateCondition(prometheusUrl: string, rule: AlertRule): Promise<EvalResult | null>;
29
+ export {};
@@ -0,0 +1,147 @@
1
+ // Maps OTel metric names to Prometheus metric names.
2
+ const METRIC_MAP = {
3
+ "gen_ai.client.request.cost": "gen_ai_client_request_cost_USD_sum",
4
+ "gen_ai.client.operation.duration": "gen_ai_client_operation_duration_milliseconds",
5
+ "gen_ai.client.requests": "gen_ai_client_requests_total",
6
+ "gen_ai.client.errors": "gen_ai_client_errors_total",
7
+ "gen_ai.client.token.usage": "gen_ai_client_token_usage_total",
8
+ // Legacy names (backward compat)
9
+ "llm.request.cost": "gen_ai_client_request_cost_USD_sum",
10
+ "llm.request.duration": "gen_ai_client_operation_duration_milliseconds",
11
+ "llm.requests": "gen_ai_client_requests_total",
12
+ "llm.errors": "gen_ai_client_errors_total",
13
+ "llm.tokens": "gen_ai_client_token_usage_total",
14
+ };
15
+ function toPromMetric(metric) {
16
+ return METRIC_MAP[metric] ?? metric.replace(/\./g, "_");
17
+ }
18
+ /**
19
+ * Parse condition string into structured form.
20
+ *
21
+ * Standard: "sum_1h > 10", "rate_5m > 0.05"
22
+ * Baseline: "p95_pct_5m_7d > 50" — p95 over 5m increased >50% vs 7d baseline
23
+ * Ratio: "ratio_15m > 0.05" — errors/requests ratio over 15m
24
+ */
25
+ export function parseCondition(condition) {
26
+ // Baseline percentage: p95_pct_<window>_<baseline> <cmp> <threshold>
27
+ const baselineMatch = /^p95_pct_(\d+[smhd])_(\d+[smhd])\s*(>=|<=|>|<)\s*([\d.]+)$/.exec(condition.trim());
28
+ if (baselineMatch) {
29
+ return {
30
+ operator: "p95_pct",
31
+ window: baselineMatch[1],
32
+ baselineWindow: baselineMatch[2],
33
+ comparator: baselineMatch[3],
34
+ threshold: parseFloat(baselineMatch[4]),
35
+ };
36
+ }
37
+ // Standard: sum_1h > 10, ratio_15m > 0.05
38
+ const match = /^(sum|avg|rate|max|ratio)_(\d+[smhd])\s*(>=|<=|>|<)\s*([\d.]+)$/.exec(condition.trim());
39
+ if (!match) {
40
+ throw new Error(`Invalid alert condition: "${condition}". Expected format: "sum_1h > 10" or "p95_pct_5m_7d > 50"`);
41
+ }
42
+ return {
43
+ operator: match[1],
44
+ window: match[2],
45
+ comparator: match[3],
46
+ threshold: parseFloat(match[4]),
47
+ };
48
+ }
49
+ function buildPromQL(metric, operator, window) {
50
+ const m = toPromMetric(metric);
51
+ switch (operator) {
52
+ case "sum":
53
+ return `sum(increase(${m}[${window}]))`;
54
+ case "avg":
55
+ return `avg(rate(${m}[${window}])) * 60`;
56
+ case "rate":
57
+ return `sum(rate(${m}[${window}])) * 60`;
58
+ case "max":
59
+ return `max(increase(${m}[${window}]))`;
60
+ case "p95_pct":
61
+ case "ratio":
62
+ // Handled separately in evaluateCondition
63
+ return "";
64
+ }
65
+ }
66
+ function buildTopModelsQuery(metric, window) {
67
+ const m = toPromMetric(metric);
68
+ return `topk(5, sum by (gen_ai_request_model) (increase(${m}[${window}])))`;
69
+ }
70
+ function matches(value, comparator, threshold) {
71
+ switch (comparator) {
72
+ case ">":
73
+ return value > threshold;
74
+ case ">=":
75
+ return value >= threshold;
76
+ case "<":
77
+ return value < threshold;
78
+ case "<=":
79
+ return value <= threshold;
80
+ }
81
+ }
82
+ async function queryScalar(prometheusUrl, promql) {
83
+ const url = `${prometheusUrl}/api/v1/query?query=${encodeURIComponent(promql)}`;
84
+ const res = await fetch(url);
85
+ if (!res.ok)
86
+ throw new Error(`Prometheus query failed: ${res.status} ${res.statusText}`);
87
+ const data = (await res.json());
88
+ const first = data.data.result[0];
89
+ return first ? parseFloat(first.value[1]) : 0;
90
+ }
91
+ async function queryTopModels(prometheusUrl, metric, window) {
92
+ const promql = buildTopModelsQuery(metric, window);
93
+ const url = `${prometheusUrl}/api/v1/query?query=${encodeURIComponent(promql)}`;
94
+ const res = await fetch(url);
95
+ if (!res.ok)
96
+ return [];
97
+ const data = (await res.json());
98
+ return data.data.result
99
+ .map((r) => ({
100
+ model: r.metric.gen_ai_request_model ?? "unknown",
101
+ value: parseFloat(r.value[1]),
102
+ }))
103
+ .sort((a, b) => b.value - a.value);
104
+ }
105
+ /** Evaluate p95 current vs baseline, return % increase */
106
+ async function evalP95Baseline(prometheusUrl, metric, window, baselineWindow) {
107
+ const m = toPromMetric(metric);
108
+ const bucketMetric = `${m}_bucket`;
109
+ const currentP95 = await queryScalar(prometheusUrl, `histogram_quantile(0.95, sum(rate(${bucketMetric}[${window}])) by (le))`);
110
+ const baselineP95 = await queryScalar(prometheusUrl, `histogram_quantile(0.95, sum(rate(${bucketMetric}[${baselineWindow}])) by (le))`);
111
+ if (baselineP95 === 0)
112
+ return 0;
113
+ return ((currentP95 - baselineP95) / baselineP95) * 100;
114
+ }
115
+ /** Evaluate error ratio: errors / requests over window */
116
+ async function evalErrorRatio(prometheusUrl, window) {
117
+ const errors = await queryScalar(prometheusUrl, `sum(increase(gen_ai_client_errors_total[${window}]))`);
118
+ const requests = await queryScalar(prometheusUrl, `sum(increase(gen_ai_client_requests_total[${window}]))`);
119
+ if (requests === 0)
120
+ return 0;
121
+ return errors / requests;
122
+ }
123
+ export async function evaluateCondition(prometheusUrl, rule) {
124
+ try {
125
+ const parsed = parseCondition(rule.condition);
126
+ let value;
127
+ if (parsed.operator === "p95_pct") {
128
+ value = await evalP95Baseline(prometheusUrl, rule.metric, parsed.window, parsed.baselineWindow);
129
+ }
130
+ else if (parsed.operator === "ratio") {
131
+ value = await evalErrorRatio(prometheusUrl, parsed.window);
132
+ }
133
+ else {
134
+ const promql = buildPromQL(rule.metric, parsed.operator, parsed.window);
135
+ value = await queryScalar(prometheusUrl, promql);
136
+ }
137
+ const triggered = matches(value, parsed.comparator, parsed.threshold);
138
+ const topModels = triggered
139
+ ? await queryTopModels(prometheusUrl, rule.metric, parsed.window)
140
+ : [];
141
+ return { triggered, value, threshold: parsed.threshold, topModels };
142
+ }
143
+ catch (err) {
144
+ console.error(`[toad-eye alerts] Failed to evaluate "${rule.name}":`, err);
145
+ return null;
146
+ }
147
+ }
@@ -0,0 +1,5 @@
1
+ export declare function postGrafanaAnnotation(grafanaUrl: string, alert: {
2
+ name: string;
3
+ value: number;
4
+ threshold: number;
5
+ }, apiKey: string | undefined): Promise<void>;