toad-eye 1.1.0 β 2.0.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 +132 -87
- package/dist/alerts/channels.d.ts +2 -0
- package/dist/alerts/channels.js +81 -0
- package/dist/alerts/conditions.d.ts +29 -0
- package/dist/alerts/conditions.js +147 -0
- package/dist/alerts/grafana.d.ts +5 -0
- package/dist/alerts/grafana.js +19 -0
- package/dist/alerts/index.d.ts +5 -0
- package/dist/alerts/index.js +12 -0
- package/dist/alerts/manager.d.ts +13 -0
- package/dist/alerts/manager.js +87 -0
- package/dist/alerts/types.d.ts +46 -0
- package/dist/alerts/types.js +1 -0
- package/dist/cli.js +72 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +3 -1
- package/dist/instrumentations/anthropic.d.ts +1 -0
- package/dist/instrumentations/anthropic.js +54 -0
- package/dist/instrumentations/create.d.ts +11 -0
- package/dist/instrumentations/create.js +67 -0
- package/dist/instrumentations/gemini.d.ts +1 -0
- package/dist/instrumentations/gemini.js +48 -0
- package/dist/instrumentations/openai.d.ts +1 -0
- package/dist/instrumentations/openai.js +58 -0
- package/dist/instrumentations/registry.d.ts +5 -0
- package/dist/instrumentations/registry.js +31 -0
- package/dist/instrumentations/types.d.ts +29 -0
- package/dist/instrumentations/types.js +1 -0
- package/dist/metrics.js +31 -16
- package/dist/pricing.d.ts +24 -0
- package/dist/pricing.js +52 -0
- package/dist/spans.d.ts +1 -1
- package/dist/spans.js +46 -20
- package/dist/tracer.d.ts +4 -1
- package/dist/tracer.js +9 -0
- package/dist/types/attributes.d.ts +36 -0
- package/dist/types/attributes.js +38 -0
- package/dist/types/config.d.ts +20 -0
- package/dist/types/config.js +1 -0
- package/dist/types/index.d.ts +17 -0
- package/dist/types/index.js +13 -0
- package/dist/types/metrics.d.ts +25 -0
- package/dist/types/metrics.js +23 -0
- package/dist/types/providers.d.ts +5 -0
- package/dist/types/providers.js +1 -0
- package/dist/types/spans.d.ts +19 -0
- package/dist/types/spans.js +1 -0
- package/dist/types.d.ts +60 -25
- package/dist/types.js +47 -21
- package/package.json +15 -4
- package/templates/alerts.example.yml +54 -0
- package/templates/docker-compose.yml +1 -2
- package/templates/grafana/dashboards/cost-breakdown.json +208 -0
- package/templates/grafana/dashboards/error-drilldown.json +188 -0
- package/templates/grafana/dashboards/latency-analysis.json +134 -0
- package/templates/grafana/dashboards/llm-overview.json +161 -30
- package/templates/grafana/dashboards/model-comparison.json +143 -0
package/README.md
CHANGED
|
@@ -1,126 +1,171 @@
|
|
|
1
1
|
# toad-eye πΈποΈ
|
|
2
2
|
|
|
3
|
-

|
|
8
|
-

|
|
9
|
-

|
|
10
|
-

|
|
11
|
-

|
|
3
|
+
[](https://www.npmjs.com/package/toad-eye)
|
|
4
|
+
[](https://www.npmjs.com/package/toad-eye)
|
|
5
|
+
[](https://github.com/vola-trebla/toad-eye/actions)
|
|
6
|
+
[](https://opensource.org/licenses/ISC)
|
|
12
7
|
|
|
13
|
-
|
|
8
|
+
**Full observability for LLM services β traces, metrics, and Grafana dashboards in one line of code.**
|
|
14
9
|
|
|
15
|
-
|
|
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
|
-
|
|
13
|
+
<!-- TODO: record GIF β terminal: npx toad-eye init β up β demo β Grafana with live data -->
|
|
14
|
+
<!--  -->
|
|
18
15
|
|
|
19
|
-
|
|
16
|
+
---
|
|
20
17
|
|
|
21
|
-
##
|
|
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
|
-
|
|
26
|
+
```typescript
|
|
27
|
+
import { initObservability } from "toad-eye";
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
+
That's it. **No wrappers. No manual spans.** All your Anthropic and OpenAI calls are traced automatically.
|
|
42
37
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## What you get
|
|
41
|
+
|
|
42
|
+

|
|
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
|
|
51
|
-
|
|
|
52
|
-
| Grafana
|
|
53
|
-
| Jaeger
|
|
54
|
-
| Prometheus
|
|
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
|
-
|
|
78
|
+
---
|
|
58
79
|
|
|
59
|
-
|
|
60
|
-
import { initObservability, traceLLMCall } from "toad-eye";
|
|
80
|
+
## Metrics
|
|
61
81
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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:
|
|
71
|
-
() =>
|
|
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
|
-
|
|
113
|
+
### Privacy mode
|
|
76
114
|
|
|
77
|
-
|
|
115
|
+
Stop recording prompt/completion content in spans β useful in production:
|
|
78
116
|
|
|
79
|
-
|
|
117
|
+
```typescript
|
|
118
|
+
initObservability({ serviceName: "my-app", recordContent: false });
|
|
119
|
+
```
|
|
80
120
|
|
|
81
|
-
|
|
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
|
-
|
|
123
|
+
Fire alerts when spend crosses a threshold:
|
|
90
124
|
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
+
### Custom OTLP endpoint
|
|
109
153
|
|
|
110
|
-
|
|
154
|
+
```typescript
|
|
155
|
+
initObservability({
|
|
156
|
+
serviceName: "my-app",
|
|
157
|
+
endpoint: "http://your-collector:4318",
|
|
158
|
+
});
|
|
159
|
+
```
|
|
111
160
|
|
|
112
|
-
|
|
161
|
+
---
|
|
113
162
|
|
|
114
|
-
##
|
|
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,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,19 @@
|
|
|
1
|
+
export async function postGrafanaAnnotation(grafanaUrl, alert, apiKey) {
|
|
2
|
+
const headers = {
|
|
3
|
+
"Content-Type": "application/json",
|
|
4
|
+
};
|
|
5
|
+
if (apiKey)
|
|
6
|
+
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
7
|
+
const res = await fetch(`${grafanaUrl}/api/annotations`, {
|
|
8
|
+
method: "POST",
|
|
9
|
+
headers,
|
|
10
|
+
body: JSON.stringify({
|
|
11
|
+
time: Date.now(),
|
|
12
|
+
tags: ["toad-eye", "alert", alert.name],
|
|
13
|
+
text: `π¨ ${alert.name}: ${alert.value.toFixed(4)} > ${alert.threshold}`,
|
|
14
|
+
}),
|
|
15
|
+
});
|
|
16
|
+
if (!res.ok) {
|
|
17
|
+
throw new Error(`Grafana annotation failed: ${res.status} ${res.statusText}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { AlertManager } from "./manager.js";
|
|
2
|
+
export { parseCondition } from "./conditions.js";
|
|
3
|
+
export type { AlertsConfig, AlertRule, AlertChannelConfig, FiredAlert, } from "./types.js";
|
|
4
|
+
import { AlertManager } from "./manager.js";
|
|
5
|
+
export declare function startAlertsFromFile(configPath: string): AlertManager;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { AlertManager } from "./manager.js";
|
|
2
|
+
export { parseCondition } from "./conditions.js";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import yaml from "yaml";
|
|
5
|
+
import { AlertManager } from "./manager.js";
|
|
6
|
+
export function startAlertsFromFile(configPath) {
|
|
7
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
8
|
+
const config = yaml.parse(raw);
|
|
9
|
+
const manager = new AlertManager(config);
|
|
10
|
+
manager.start();
|
|
11
|
+
return manager;
|
|
12
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AlertsConfig } from "./types.js";
|
|
2
|
+
export declare class AlertManager {
|
|
3
|
+
private readonly config;
|
|
4
|
+
private readonly cooldowns;
|
|
5
|
+
private intervalId;
|
|
6
|
+
constructor(config: AlertsConfig);
|
|
7
|
+
start(): void;
|
|
8
|
+
stop(): void;
|
|
9
|
+
private isInCooldown;
|
|
10
|
+
private evaluate;
|
|
11
|
+
private fireChannels;
|
|
12
|
+
private postAnnotation;
|
|
13
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { evaluateCondition } from "./conditions.js";
|
|
2
|
+
import { sendToChannel } from "./channels.js";
|
|
3
|
+
import { postGrafanaAnnotation } from "./grafana.js";
|
|
4
|
+
const DEFAULT_PROMETHEUS_URL = "http://localhost:9090";
|
|
5
|
+
const DEFAULT_GRAFANA_URL = "http://localhost:3100";
|
|
6
|
+
const DEFAULT_EVAL_INTERVAL_SECONDS = 60;
|
|
7
|
+
const DEFAULT_COOLDOWN_MINUTES = 30;
|
|
8
|
+
export class AlertManager {
|
|
9
|
+
config;
|
|
10
|
+
cooldowns = new Map();
|
|
11
|
+
intervalId;
|
|
12
|
+
constructor(config) {
|
|
13
|
+
this.config = config;
|
|
14
|
+
}
|
|
15
|
+
start() {
|
|
16
|
+
const intervalMs = (this.config.evalIntervalSeconds ?? DEFAULT_EVAL_INTERVAL_SECONDS) * 1000;
|
|
17
|
+
this.intervalId = setInterval(() => void this.evaluate(), intervalMs);
|
|
18
|
+
void this.evaluate();
|
|
19
|
+
console.log(`[toad-eye alerts] Started β ${this.config.alerts.length} rule(s), eval every ${intervalMs / 1000}s`);
|
|
20
|
+
}
|
|
21
|
+
stop() {
|
|
22
|
+
if (this.intervalId !== undefined) {
|
|
23
|
+
clearInterval(this.intervalId);
|
|
24
|
+
this.intervalId = undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
isInCooldown(rule) {
|
|
28
|
+
const lastFired = this.cooldowns.get(rule.name);
|
|
29
|
+
if (lastFired === undefined)
|
|
30
|
+
return false;
|
|
31
|
+
const cooldownMs = (rule.cooldown ??
|
|
32
|
+
this.config.cooldownMinutes ??
|
|
33
|
+
DEFAULT_COOLDOWN_MINUTES) *
|
|
34
|
+
60 *
|
|
35
|
+
1000;
|
|
36
|
+
return Date.now() - lastFired < cooldownMs;
|
|
37
|
+
}
|
|
38
|
+
async evaluate() {
|
|
39
|
+
const prometheusUrl = this.config.prometheusUrl ?? DEFAULT_PROMETHEUS_URL;
|
|
40
|
+
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
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async fireChannels(alert) {
|
|
63
|
+
const channels = this.config.channels ?? {};
|
|
64
|
+
for (const channelName of alert.rule.channels) {
|
|
65
|
+
const channelConfig = channels[channelName];
|
|
66
|
+
if (!channelConfig) {
|
|
67
|
+
console.warn(`[toad-eye alerts] Channel "${channelName}" not configured, skipping`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
await sendToChannel(channelConfig, alert);
|
|
72
|
+
console.log(`[toad-eye alerts] β Sent to channel "${channelName}"`);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
console.error(`[toad-eye alerts] β Failed to send to channel "${channelName}":`, err);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async postAnnotation(grafanaUrl, name, value, threshold) {
|
|
80
|
+
try {
|
|
81
|
+
await postGrafanaAnnotation(grafanaUrl, { name, value, threshold }, this.config.grafanaApiKey);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
console.error(`[toad-eye alerts] Failed to post Grafana annotation:`, err);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|