Skip to content

Commit 9f34ab3

Browse files
Reflexclaude
andcommitted
feat(loadtest): daily GHA job + Loki push for perf trend tracking
Adds: - loadtest/push-to-loki.ts — runs all four load scripts and POSTs one structured JSON log line per test to Loki (job="h2-loadtest") - .github/workflows/h2-loadtest.yml — daily cron (06:00 UTC), connects via Tailscale tag:ci, pushes to https://dev-loki - README update documenting the automated run and local push usage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a8def52 commit 9f34ab3

3 files changed

Lines changed: 169 additions & 0 deletions

File tree

.github/workflows/h2-loadtest.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: H2 Load Tests
2+
3+
on:
4+
schedule:
5+
- cron: '0 6 * * *' # 06:00 UTC daily
6+
workflow_dispatch:
7+
8+
jobs:
9+
loadtest:
10+
name: h2-transport load tests
11+
runs-on: ubuntu-latest
12+
timeout-minutes: 15
13+
14+
steps:
15+
- uses: runloopai/checkout@main
16+
17+
- name: Set up Node
18+
uses: runloopai/setup-node@main
19+
with:
20+
node-version: '20'
21+
22+
- name: Install dependencies
23+
run: yarn --frozen-lockfile
24+
25+
- name: Connect to Tailscale
26+
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4.1.2
27+
with:
28+
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
29+
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
30+
tags: tag:ci
31+
hostname: github-ci-${{ github.run_id }}-loadtest
32+
33+
- name: Run load tests and push to Loki
34+
env:
35+
LOKI_URL: https://dev-loki
36+
run: npx tsx loadtest/push-to-loki.ts

loadtest/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,25 @@ duration. Not part of `npm test`; run on demand.
1414
| `h2-chaos.ts` | Server randomly drops sockets / RSTs / GOAWAYs / delays. Asserts ≥50 % GET success. |
1515
| `h2load.sh` | Wraps `nghttp2`'s `h2load` against a long-running test server. |
1616
| `sse-test.ts` | Pre-existing manual SSE round-trip. |
17+
| `push-to-loki.ts` | Runs all four timed tests and pushes results to Loki for Grafana. |
18+
19+
## Automated daily runs
20+
21+
The `.github/workflows/h2-loadtest.yml` workflow fires at 06:00 UTC every day.
22+
It connects to the dev Tailscale network and runs `push-to-loki.ts`, which
23+
executes each script with CI-appropriate durations (multiplex N=1000,
24+
chaos 30 s, leak 120 s) and posts one structured JSON log line per test to
25+
Loki under `{job="h2-loadtest", test="<name>"}`.
26+
27+
Results are visible in the **H2 Transport Load Tests** Grafana dashboard on
28+
dev-grafana. Each panel uses `last_over_time(...[1d])` to produce one data
29+
point per daily run, so regressions show up as step changes on the trend lines.
30+
31+
To push results from a local run (requires Tailscale + access to dev-loki):
32+
33+
```sh
34+
LOKI_URL=https://dev-loki npx tsx loadtest/push-to-loki.ts
35+
```
1736

1837
## Run
1938

loadtest/push-to-loki.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* Runs all h2-transport load tests and pushes results to Loki as structured
3+
* log entries. One log line per test per run, labelled {job="h2-loadtest",
4+
* test="<name>"}. Grafana reads these via LogQL unwrap to chart trends.
5+
*
6+
* Usage:
7+
* LOKI_URL=https://dev-loki npx tsx loadtest/push-to-loki.ts
8+
*
9+
* LOKI_URL must be the Loki gateway base URL (no trailing slash, no path).
10+
* Optionally set LOKI_USER and LOKI_PASSWORD for basic auth.
11+
*/
12+
import { execFileSync } from 'node:child_process';
13+
import path from 'node:path';
14+
import { fileURLToPath } from 'node:url';
15+
16+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
17+
18+
const LOKI_URL = process.env['LOKI_URL'];
19+
if (!LOKI_URL) {
20+
console.error('LOKI_URL is required');
21+
process.exit(1);
22+
}
23+
24+
const LOKI_USER = process.env['LOKI_USER'] ?? '';
25+
const LOKI_PASSWORD = process.env['LOKI_PASSWORD'] ?? '';
26+
27+
interface Test {
28+
name: string;
29+
script: string;
30+
args?: string[];
31+
}
32+
33+
const TESTS: Test[] = [
34+
{ name: 'multiplex', script: 'h2-multiplex.ts', args: ['1000'] },
35+
{ name: 'pool-growth', script: 'h2-pool-growth.ts' },
36+
{ name: 'chaos', script: 'h2-chaos.ts', args: ['30'] },
37+
{ name: 'leak', script: 'h2-leak.ts', args: ['120'] },
38+
];
39+
40+
function runTest(test: Test): unknown {
41+
const scriptPath = path.join(__dirname, test.script);
42+
const args = ['tsx', scriptPath, ...(test.args ?? [])];
43+
console.log(`running ${test.name}...`);
44+
const stdout = execFileSync('npx', args, {
45+
encoding: 'utf8',
46+
timeout: 300_000,
47+
stdio: ['ignore', 'pipe', 'inherit'],
48+
});
49+
// Scripts may print progress lines before the final JSON object; extract it.
50+
const match = stdout.match(/\{[\s\S]*\}(?=[^{}]*$)/);
51+
if (!match) throw new Error(`no JSON in output of ${test.name}`);
52+
return JSON.parse(match[0]);
53+
}
54+
55+
async function pushToLoki(test: string, result: unknown): Promise<void> {
56+
const nowNs = String(BigInt(Date.now()) * 1_000_000n);
57+
const body = JSON.stringify({
58+
streams: [
59+
{
60+
stream: { job: 'h2-loadtest', test },
61+
values: [[nowNs, JSON.stringify(result)]],
62+
},
63+
],
64+
});
65+
66+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
67+
if (LOKI_USER && LOKI_PASSWORD) {
68+
headers['Authorization'] = 'Basic ' + Buffer.from(`${LOKI_USER}:${LOKI_PASSWORD}`).toString('base64');
69+
}
70+
71+
const res = await fetch(`${LOKI_URL}/loki/api/v1/push`, {
72+
method: 'POST',
73+
headers,
74+
body,
75+
});
76+
if (!res.ok) {
77+
const text = await res.text();
78+
throw new Error(`Loki push failed for ${test}: ${res.status} ${text}`);
79+
}
80+
console.log(`pushed ${test} → Loki (${res.status})`);
81+
}
82+
83+
async function main() {
84+
const results: Array<{ test: string; result: unknown }> = [];
85+
86+
for (const test of TESTS) {
87+
try {
88+
const result = runTest(test);
89+
results.push({ test: test.name, result });
90+
} catch (err) {
91+
console.error(`${test.name} failed:`, err);
92+
process.exitCode = 1;
93+
}
94+
}
95+
96+
for (const { test, result } of results) {
97+
try {
98+
await pushToLoki(test, result);
99+
} catch (err) {
100+
console.error(`push failed for ${test}:`, err);
101+
process.exitCode = 1;
102+
}
103+
}
104+
105+
console.log('\nsummary:');
106+
for (const { test, result } of results) {
107+
console.log(` ${test}:`, JSON.stringify(result));
108+
}
109+
}
110+
111+
main().catch((err) => {
112+
console.error(err);
113+
process.exit(1);
114+
});

0 commit comments

Comments
 (0)