Skip to content

Commit 47411e0

Browse files
committed
Add Dockerfile and convert Grafana-GitHub bridge to standard Node.js
Convert from Cloudflare Worker to a plain Node.js HTTP server so it can be deployed to any container infrastructure. No external dependencies — uses only Node.js stdlib (node:http). Adds Dockerfile, health endpoint, smoke tests, and deployment docs. Addresses paritytech/devops#5019 feedback on PR #3224.
1 parent d638238 commit 47411e0

6 files changed

Lines changed: 474 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules/
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
FROM node:22-alpine
2+
3+
WORKDIR /app
4+
5+
COPY package.json ./
6+
# No dependencies to install — stdlib only
7+
8+
COPY src/ ./src/
9+
10+
EXPOSE 3000
11+
12+
CMD ["node", "src/index.js"]
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Grafana → GitHub Alert Bridge
2+
3+
Receives Grafana Alertmanager webhook POSTs and creates categorized GitHub issues in `paritytech/parity-bridges-common`.
4+
5+
## Quick start
6+
7+
```bash
8+
# Run directly
9+
GITHUB_TOKEN=ghp_xxx node src/index.js
10+
11+
# Or with Docker
12+
docker build -t grafana-github-bridge .
13+
docker run -p 3000:3000 \
14+
-e GITHUB_TOKEN=ghp_xxx \
15+
-e WEBHOOK_SECRET=optional-secret \
16+
grafana-github-bridge
17+
```
18+
19+
## Environment variables
20+
21+
| Variable | Required | Description |
22+
|----------|----------|-------------|
23+
| `GITHUB_TOKEN` | Yes | GitHub PAT with `issues:write` scope |
24+
| `WEBHOOK_SECRET` | No | Shared secret — if set, requests must include `Authorization: Bearer <secret>` |
25+
| `PORT` | No | Listen port (default: `3000`) |
26+
27+
## Endpoints
28+
29+
- `POST /` — Grafana webhook receiver
30+
- `GET /health` — Health check (returns `{"status":"ok"}`)
31+
32+
## Grafana configuration
33+
34+
Add a contact point of type **Webhook** with:
35+
- **URL**: `http://<host>:3000/`
36+
- **HTTP Method**: `POST`
37+
- If `WEBHOOK_SECRET` is set, add header: `Authorization: Bearer <your-secret>`
38+
39+
## Testing
40+
41+
```bash
42+
GITHUB_TOKEN=fake PORT=9876 node test.js
43+
```
44+
45+
## Alert classification
46+
47+
Alerts are classified by their `alertname` label into categories: `relay-down`, `version-guard`, `headers-mismatch`, `finality-lag`, `delivery-lag`, `confirmation-lag`, `reward-lag`, `low-balance`, or `other`.
48+
49+
Critical alerts and unclassified alerts get the `claude-escalate` label; others get the `claude` label.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "grafana-github-bridge",
3+
"version": "1.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"start": "node src/index.js",
8+
"test": "node test.js"
9+
},
10+
"engines": {
11+
"node": ">=18"
12+
}
13+
}
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
/**
2+
* Grafana → GitHub Issue bridge.
3+
*
4+
* Receives Grafana Alertmanager webhook POSTs and creates GitHub issues
5+
* with the "alert" label, categorised by bridge alert type.
6+
*
7+
* Environment variables:
8+
* GITHUB_TOKEN – GitHub PAT with `issues:write` scope
9+
* WEBHOOK_SECRET – (optional) shared secret for request validation
10+
* PORT – HTTP listen port (default 3000)
11+
*/
12+
13+
import http from 'node:http';
14+
15+
const REPO = 'paritytech/parity-bridges-common';
16+
17+
// ---------------------------------------------------------------------------
18+
// Alert classification
19+
// ---------------------------------------------------------------------------
20+
21+
const ALERT_CATEGORIES = [
22+
{
23+
id: 'relay-down',
24+
label: 'relay-down',
25+
match: (t) => /node is down/i.test(t),
26+
emoji: '🔴',
27+
action: 'Check relay pod status and restart if needed.',
28+
},
29+
{
30+
id: 'version-guard',
31+
label: 'version-guard',
32+
match: (t) => /version guard|abort/i.test(t),
33+
emoji: '⛔',
34+
action:
35+
'A chain was upgraded — redeploy the relay with the new runtime.',
36+
},
37+
{
38+
id: 'headers-mismatch',
39+
label: 'headers-mismatch',
40+
match: (t) => /headers? mismatch|different.?forks/i.test(t),
41+
emoji: '🔀',
42+
action:
43+
'Source chain forked — the relay may need to re-sync headers from the canonical fork.',
44+
},
45+
{
46+
id: 'finality-lag',
47+
label: 'finality-lag',
48+
match: (t) => /finality.*lag|sync.*lag/i.test(t),
49+
emoji: '⏳',
50+
action:
51+
'Finality headers are not advancing — check relay logs and source chain finality.',
52+
},
53+
{
54+
id: 'delivery-lag',
55+
label: 'delivery-lag',
56+
match: (t) => /delivery.*lag/i.test(t),
57+
emoji: '📦',
58+
action:
59+
'Messages generated but not delivered — check message relay process.',
60+
},
61+
{
62+
id: 'confirmation-lag',
63+
label: 'confirmation-lag',
64+
match: (t) => /confirmation.*lag/i.test(t),
65+
emoji: '✅',
66+
action:
67+
'Messages delivered but not confirmed back to source — check confirmation relay.',
68+
},
69+
{
70+
id: 'reward-lag',
71+
label: 'reward-lag',
72+
match: (t) => /reward.*lag/i.test(t),
73+
emoji: '💰',
74+
action:
75+
'Confirmations not being rewarded — check reward mechanism and relay balance.',
76+
},
77+
{
78+
id: 'low-balance',
79+
label: 'low-balance',
80+
match: (t) => /balance/i.test(t),
81+
emoji: '💸',
82+
action: 'Relay account balance is low — top up the account.',
83+
},
84+
];
85+
86+
function classify(alertname) {
87+
for (const cat of ALERT_CATEGORIES) {
88+
if (cat.match(alertname)) return cat;
89+
}
90+
return {
91+
id: 'other',
92+
label: 'bridge-alert',
93+
emoji: '⚠️',
94+
action: null,
95+
};
96+
}
97+
98+
// Extract environment (prod vs testnet) from labels or title
99+
function detectEnv(alert) {
100+
const domain = alert.labels?.domain || '';
101+
const title = alert.labels?.alertname || '';
102+
if (domain === 'parity-testnet' || /rococo|westend/i.test(title))
103+
return 'testnet';
104+
if (domain === 'parity-chains' || /polkadot|kusama/i.test(title))
105+
return 'production';
106+
return 'unknown';
107+
}
108+
109+
// Extract the bridge pair from the alert title, e.g. "Polkadot <> Kusama"
110+
function detectBridgePair(alert) {
111+
const title = alert.labels?.alertname || '';
112+
const m = title.match(
113+
/(\w+?)(?:BridgeHub)?\s*(?:->|<>|to)\s*(\w+?)(?:BridgeHub)?[\s_]/i,
114+
);
115+
if (m) return `${m[1]}${m[2]}`;
116+
return alert.labels?.bridge || null;
117+
}
118+
119+
// ---------------------------------------------------------------------------
120+
// Issue formatting
121+
// ---------------------------------------------------------------------------
122+
123+
function formatTitle(alert, category) {
124+
const alertname = alert.labels?.alertname || 'Unknown alert';
125+
return `${category.emoji} [Alert] ${alertname}`;
126+
}
127+
128+
function formatBody(alert, payload, category, env, bridgePair) {
129+
const labels = alert.labels || {};
130+
const annotations = alert.annotations || {};
131+
const values = alert.values || {};
132+
133+
const lines = [
134+
`## ${category.emoji} ${labels.alertname || 'Alert'}`,
135+
'',
136+
`| Field | Value |`,
137+
`|-------|-------|`,
138+
`| **Status** | \`${alert.status}\` |`,
139+
`| **Severity** | \`${labels.severity || 'unknown'}\` |`,
140+
`| **Category** | \`${category.id}\` |`,
141+
`| **Environment** | \`${env}\` |`,
142+
bridgePair ? `| **Bridge** | \`${bridgePair}\` |` : null,
143+
`| **Started** | ${alert.startsAt || 'N/A'} |`,
144+
'',
145+
];
146+
147+
if (annotations.summary) {
148+
lines.push(`### Summary`, '', annotations.summary, '');
149+
}
150+
if (annotations.description) {
151+
lines.push(`### Description`, '', annotations.description, '');
152+
}
153+
154+
if (category.action) {
155+
lines.push(`### Suggested Action`, '', `> ${category.action}`, '');
156+
}
157+
158+
if (Object.keys(values).length > 0) {
159+
lines.push('### Metric Values', '');
160+
for (const [key, val] of Object.entries(values)) {
161+
lines.push(`- **${key}:** \`${val}\``);
162+
}
163+
lines.push('');
164+
}
165+
166+
// Links
167+
const linkLines = [];
168+
if (alert.generatorURL) linkLines.push(`- [Alert rule](${alert.generatorURL})`);
169+
if (payload.externalURL) linkLines.push(`- [Grafana](${payload.externalURL})`);
170+
if (annotations.__dashboardUid__) {
171+
const base = payload.externalURL || 'https://grafana.teleport.parity.io';
172+
const dashUrl = `${base}/d/${annotations.__dashboardUid__}`;
173+
linkLines.push(`- [Dashboard](${dashUrl})`);
174+
}
175+
if (linkLines.length) {
176+
lines.push('### Links', '', ...linkLines, '');
177+
}
178+
179+
// All labels
180+
lines.push(
181+
'<details><summary>All labels</summary>',
182+
'',
183+
'```json',
184+
JSON.stringify(labels, null, 2),
185+
'```',
186+
'',
187+
'</details>',
188+
'',
189+
'<details><summary>Raw alert payload</summary>',
190+
'',
191+
'```json',
192+
JSON.stringify(alert, null, 2),
193+
'```',
194+
'',
195+
'</details>',
196+
);
197+
198+
return lines.filter((l) => l !== null).join('\n');
199+
}
200+
201+
// ---------------------------------------------------------------------------
202+
// HTTP handler
203+
// ---------------------------------------------------------------------------
204+
205+
async function handleRequest(req, res) {
206+
if (req.method === 'GET' && req.url === '/health') {
207+
res.writeHead(200, { 'Content-Type': 'application/json' });
208+
res.end(JSON.stringify({ status: 'ok' }));
209+
return;
210+
}
211+
212+
if (req.method !== 'POST') {
213+
res.writeHead(405, { 'Content-Type': 'text/plain' });
214+
res.end('Method not allowed');
215+
return;
216+
}
217+
218+
const webhookSecret = process.env.WEBHOOK_SECRET;
219+
if (webhookSecret) {
220+
const auth = req.headers['authorization'];
221+
if (auth !== `Bearer ${webhookSecret}`) {
222+
res.writeHead(401, { 'Content-Type': 'text/plain' });
223+
res.end('Unauthorized');
224+
return;
225+
}
226+
}
227+
228+
let body = '';
229+
for await (const chunk of req) {
230+
body += chunk;
231+
}
232+
233+
let payload;
234+
try {
235+
payload = JSON.parse(body);
236+
} catch {
237+
res.writeHead(400, { 'Content-Type': 'text/plain' });
238+
res.end('Invalid JSON');
239+
return;
240+
}
241+
242+
const alerts = payload.alerts || [];
243+
const results = [];
244+
245+
for (const alert of alerts) {
246+
if (alert.status !== 'firing') continue;
247+
248+
const alertname = alert.labels?.alertname || 'Unknown alert';
249+
const category = classify(alertname);
250+
const envName = detectEnv(alert);
251+
const bridgePair = detectBridgePair(alert);
252+
253+
const title = formatTitle(alert, category);
254+
const issueBody = formatBody(alert, payload, category, envName, bridgePair);
255+
256+
const severity = alert.labels?.severity || 'warning';
257+
const ghLabels = ['alert', category.label];
258+
if (envName === 'testnet') ghLabels.push('testnet');
259+
if (envName === 'production') ghLabels.push('production');
260+
261+
// Tiered model: critical/unknown → Sonnet (escalate), others → Haiku (triage)
262+
if (severity === 'critical' || category.id === 'other') {
263+
ghLabels.push('claude-escalate');
264+
} else {
265+
ghLabels.push('claude');
266+
}
267+
268+
const ghToken = process.env.GITHUB_TOKEN;
269+
const resp = await fetch(
270+
`https://api.github.com/repos/${REPO}/issues`,
271+
{
272+
method: 'POST',
273+
headers: {
274+
Authorization: `Bearer ${ghToken}`,
275+
Accept: 'application/vnd.github+json',
276+
'User-Agent': 'grafana-github-bridge',
277+
},
278+
body: JSON.stringify({
279+
title,
280+
body: issueBody,
281+
labels: ghLabels,
282+
assignees: [],
283+
}),
284+
},
285+
);
286+
287+
const respBody = resp.status === 201 ? await resp.json() : null;
288+
results.push({
289+
alertname,
290+
category: category.id,
291+
env: envName,
292+
status: resp.status,
293+
issue: respBody?.html_url || null,
294+
});
295+
}
296+
297+
res.writeHead(200, { 'Content-Type': 'application/json' });
298+
res.end(JSON.stringify({ processed: results.length, results }));
299+
}
300+
301+
// ---------------------------------------------------------------------------
302+
// Server
303+
// ---------------------------------------------------------------------------
304+
305+
const PORT = parseInt(process.env.PORT || '3000', 10);
306+
const server = http.createServer(handleRequest);
307+
server.listen(PORT, () => {
308+
console.log(`grafana-github-bridge listening on port ${PORT}`);
309+
});

0 commit comments

Comments
 (0)