har-to-slo 0.0.3

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/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.0.3] - 2026-06-11
6
+
7
+ ## [0.0.2] - 2026-06-11
8
+
9
+ ### Added
10
+ - Initial release
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 aks-builds
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,164 @@
1
+ <div align="center">
2
+
3
+ # 📐 har-to-slo
4
+
5
+ **Your HAR file already knows your p95. You just haven't asked it yet.**
6
+
7
+ Every time you record a HAR — from Playwright, Chrome DevTools, an API gateway export, or a browser session —
8
+ you're capturing real latency measurements for every route in your app. `har-to-slo` reads those measurements,
9
+ computes p95 baselines per route, and outputs a ready-to-use k6 `thresholds {}` block.
10
+ **Stop inventing SLO numbers. Derive them.**
11
+
12
+ > **Not a HAR-to-k6-script converter.** [`grafana/har-to-k6`](https://github.com/grafana/har-to-k6) replays traffic.
13
+ > `har-to-slo` reads the *timings* in that same file and turns them into statistically grounded SLO baselines.
14
+ > They complement each other — use both.
15
+
16
+ [![CI](https://github.com/aks-builds/har-to-slo/actions/workflows/ci.yml/badge.svg)](https://github.com/aks-builds/har-to-slo/actions/workflows/ci.yml)
17
+ [![CodeQL](https://github.com/aks-builds/har-to-slo/actions/workflows/codeql.yml/badge.svg)](https://github.com/aks-builds/har-to-slo/actions/workflows/codeql.yml)
18
+ [![npm version](https://img.shields.io/npm/v/har-to-slo.svg)](https://www.npmjs.com/package/har-to-slo)
19
+ [![License MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
20
+ [![Agent Skill](https://img.shields.io/badge/agent-skill-8a2be2.svg)](skills/har-to-slo.md)
21
+
22
+ <br/>
23
+
24
+ ![har-to-slo running against a real 48-entry HAR — 6 routes collapsed, p95 baselines with 1.5× multiplier](.github/media/demo.svg)
25
+
26
+ <sub>☝️ Real output from a 48-entry HAR. Six routes, all HTTP methods, zero configuration.</sub>
27
+
28
+ </div>
29
+
30
+ ---
31
+
32
+ ## The problem it solves
33
+
34
+ When you add a k6 `thresholds {}` block, you need a number:
35
+
36
+ ```javascript
37
+ "http_req_duration{url:/api/orders}": ["p(95)<???"],
38
+ ```
39
+
40
+ Where does that number come from? Usually: a guess, a round number, or copied from another service.
41
+ `har-to-slo` answers that question with **measured data from your own traffic**.
42
+
43
+ ---
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ # No install needed — run with npx
49
+ npx har-to-slo --input recording.har
50
+ ```
51
+
52
+ ---
53
+
54
+ ## Usage
55
+
56
+ ```bash
57
+ # Basic — outputs k6 thresholds block to stdout
58
+ npx har-to-slo --input recording.har
59
+
60
+ # Tighter SLOs (1.2× p95 instead of default 1.5×)
61
+ npx har-to-slo --input recording.har --multiplier 1.2
62
+
63
+ # JSON — for CI pipelines and tooling integrations
64
+ npx har-to-slo --input recording.har --format json
65
+
66
+ # Write directly into your k6 script file
67
+ npx har-to-slo --input recording.har --output k6/thresholds.js
68
+
69
+ # With LLM rationale for each threshold (requires ANTHROPIC_API_KEY)
70
+ npx har-to-slo --input recording.har --explain
71
+ ```
72
+
73
+ ---
74
+
75
+ ## How it works
76
+
77
+ ```
78
+ HAR file
79
+ └─ entry.time per request (real latency in ms)
80
+ └─ collapse URLs: /users/123 → /users/{id}
81
+ └─ group by METHOD + template
82
+ └─ trim top 5% outliers
83
+ └─ compute p95 per group
84
+ └─ threshold = Math.round(p95 × multiplier)
85
+ └─ emit k6 thresholds {} block
86
+ ```
87
+
88
+ A HAR entry's `.time` field is the total wall-clock duration of that request as observed by the client.
89
+ This is the same number your users experience. It's the right number for an SLO.
90
+
91
+ ---
92
+
93
+ ## Output example
94
+
95
+ Given a HAR with requests to 6 routes:
96
+
97
+ ```javascript
98
+ // Auto-generated by har-to-slo
99
+ // https://github.com/aks-builds/har-to-slo
100
+
101
+ export const options = {
102
+ thresholds: {
103
+ // GET /api/v1/users/{id} — p95 baseline: 142ms (n=8, ×1.5)
104
+ "http_req_duration{scenario:GET_/api/v1/users/_id_}": ["p(95)<213"],
105
+ // GET /api/v1/products — p95 baseline: 250ms (n=8, ×1.5)
106
+ "http_req_duration{scenario:GET_/api/v1/products}": ["p(95)<375"],
107
+ // POST /api/v1/orders — p95 baseline: 610ms (n=8, ×1.5)
108
+ "http_req_duration{scenario:POST_/api/v1/orders}": ["p(95)<915"],
109
+ },
110
+ };
111
+ ```
112
+
113
+ ---
114
+
115
+ ## Using alongside grafana/har-to-k6
116
+
117
+ These tools do different things and work together:
118
+
119
+ ```bash
120
+ # Step 1 — generate a k6 load test script from your HAR recording
121
+ npx har-to-k6 recording.har -o load-test.js
122
+
123
+ # Step 2 — generate statistically grounded thresholds for that script
124
+ npx har-to-slo --input recording.har --output thresholds.js
125
+
126
+ # Step 3 — merge thresholds.js into load-test.js
127
+ ```
128
+
129
+ `har-to-k6` asks: *"what requests should my load test replay?"*
130
+ `har-to-slo` asks: *"what latency should I fail the load test at?"*
131
+
132
+ ---
133
+
134
+ ## Options
135
+
136
+ | Flag | Default | Description |
137
+ |---|---|---|
138
+ | `--input`, `-i` | *(required)* | Path to HAR file |
139
+ | `--multiplier`, `-m` | `1.5` | Threshold = p95 × multiplier |
140
+ | `--format`, `-f` | `js` | Output format: `js` or `json` |
141
+ | `--output`, `-o` | stdout | Write to file instead of stdout |
142
+ | `--explain`, `-e` | off | Annotate with Claude Haiku rationale (needs `ANTHROPIC_API_KEY`) |
143
+
144
+ ---
145
+
146
+ ## E2E test results
147
+
148
+ | Dataset | Entries | Routes | Status |
149
+ |---|---|---|---|
150
+ | Small | 5 | 3 | ✅ |
151
+ | Medium | 48 | 6 | ✅ |
152
+ | Large | 500 | 10 (with UUIDs) | ✅ |
153
+
154
+ ---
155
+
156
+ ## Claude Code Skill
157
+
158
+ A bundled Claude Code skill lets AI agents call `har-to-slo` during PR review to check whether load test thresholds are grounded in real data or invented.
159
+
160
+ ---
161
+
162
+ ## License
163
+
164
+ MIT © [aks-builds](https://github.com/aks-builds)
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "har-to-slo",
3
+ "version": "0.0.3",
4
+ "description": "Turn HAR timing data into k6 SLO baselines — your recordings already know your p95",
5
+ "type": "module",
6
+ "bin": {
7
+ "har-to-slo": "src/cli.js"
8
+ },
9
+ "files": [
10
+ "src/",
11
+ "skills/",
12
+ "README.md",
13
+ "LICENSE",
14
+ "CHANGELOG.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "author": "aks-builds <its.aks@outlook.com>",
20
+ "license": "MIT",
21
+ "scripts": {
22
+ "test": "node --test tests/emitter.test.js tests/integration.test.js tests/parser.test.js tests/routes.test.js tests/stats.test.js",
23
+ "test:watch": "node --test --watch tests/**/*.test.js"
24
+ },
25
+ "peerDependencies": {
26
+ "@anthropic-ai/sdk": ">=0.20.0"
27
+ },
28
+ "peerDependenciesMeta": {
29
+ "@anthropic-ai/sdk": {
30
+ "optional": true
31
+ }
32
+ },
33
+ "keywords": [
34
+ "slo",
35
+ "har",
36
+ "k6",
37
+ "performance-baseline",
38
+ "load-testing",
39
+ "thresholds",
40
+ "p95",
41
+ "slo-generator"
42
+ ],
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "https://github.com/aks-builds/har-to-slo"
46
+ }
47
+ }
@@ -0,0 +1,57 @@
1
+ ---
2
+ name: har-to-slo
3
+ description: Turn HAR timing measurements into k6 SLO baselines. Computes p95 per route, applies configurable multiplier, outputs a ready-to-use thresholds{} block. Your recordings already know your p95.
4
+ version: 0.0.1
5
+ tools:
6
+ - Bash
7
+ - Read
8
+ - Write
9
+ ---
10
+
11
+ # har-to-slo
12
+
13
+ Convert a HAR file's measured timing data into a ready-to-use k6 `thresholds {}` block.
14
+
15
+ ## When to use
16
+ - You have a HAR file from a browser session, Playwright recording, or API gateway export
17
+ - You need to set k6 thresholds but don't know what values to use
18
+ - You want your load test SLOs grounded in real observed latency, not guesses
19
+
20
+ ## How to use
21
+
22
+ ```bash
23
+ # Basic — outputs k6 thresholds block to stdout
24
+ npx har-to-slo --input recording.har
25
+
26
+ # Stricter SLOs (1.2× p95 instead of default 1.5×)
27
+ npx har-to-slo --input recording.har --multiplier 1.2
28
+
29
+ # JSON output for CI pipelines
30
+ npx har-to-slo --input recording.har --format json
31
+
32
+ # Write directly to a k6 script file
33
+ npx har-to-slo --input recording.har --output k6/thresholds.js
34
+
35
+ # With LLM rationale annotations (requires ANTHROPIC_API_KEY)
36
+ npx har-to-slo --input recording.har --explain
37
+ ```
38
+
39
+ ## What it does
40
+ 1. Reads every HAR entry's `.time` value (total request duration in ms)
41
+ 2. Collapses parameterised URLs: `/users/123` → `/users/{id}`
42
+ 3. Groups by `METHOD /template`
43
+ 4. Trims top 5% outliers, then computes p50/p75/p95/p99
44
+ 5. Sets threshold = `Math.round(p95 × multiplier)` per group
45
+ 6. Emits a valid k6 `export const options = { thresholds: {...} }` block
46
+
47
+ ## Output example
48
+ ```javascript
49
+ export const options = {
50
+ thresholds: {
51
+ // GET /users/{id} — p95 baseline: 245ms (n=847, ×1.5)
52
+ "http_req_duration{scenario:GET_users__id_}": ["p(95)<368"],
53
+ // POST /orders — p95 baseline: 890ms (n=312, ×1.5)
54
+ "http_req_duration{scenario:POST_orders}": ["p(95)<1335"],
55
+ },
56
+ };
57
+ ```
package/src/cli.js ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ // src/cli.js
3
+ import { parseArgs } from 'node:util';
4
+ import { writeFileSync } from 'node:fs';
5
+ import { parseHarFile } from './parser.js';
6
+ import { collapseUrl } from './routes.js';
7
+ import { computeGroups } from './stats.js';
8
+ import { emitThresholds } from './emitter.js';
9
+
10
+ const { values: argv } = parseArgs({
11
+ options: {
12
+ input: { type: 'string', short: 'i' },
13
+ multiplier: { type: 'string', short: 'm', default: '1.5' },
14
+ format: { type: 'string', short: 'f', default: 'js' },
15
+ output: { type: 'string', short: 'o' },
16
+ explain: { type: 'boolean', short: 'e', default: false },
17
+ help: { type: 'boolean', short: 'h', default: false },
18
+ },
19
+ allowPositionals: false,
20
+ strict: false,
21
+ });
22
+
23
+ if (argv.help) {
24
+ process.stdout.write(`
25
+ har-to-k6-thresholds — derive k6 SLO thresholds from real HAR timing data
26
+
27
+ Usage:
28
+ har-to-k6-thresholds --input recording.har [options]
29
+
30
+ Options:
31
+ --input, -i Path to HAR file (required)
32
+ --multiplier, -m Threshold = p95 × multiplier (default: 1.5)
33
+ --format, -f Output format: js (default) or json
34
+ --output, -o Write to file instead of stdout
35
+ --explain, -e Annotate thresholds with Claude Haiku rationale
36
+ --help, -h Show this help
37
+ `);
38
+ process.exit(0);
39
+ }
40
+
41
+ if (!argv.input) {
42
+ process.stderr.write('Error: --input <path-to-har> is required\n');
43
+ process.exit(1);
44
+ }
45
+
46
+ const multiplier = parseFloat(argv.multiplier);
47
+ if (isNaN(multiplier) || multiplier <= 0) {
48
+ process.stderr.write('Error: --multiplier must be a positive number\n');
49
+ process.exit(1);
50
+ }
51
+
52
+ try {
53
+ const entries = await parseHarFile(argv.input);
54
+ const groups = computeGroups(entries, collapseUrl);
55
+
56
+ let result;
57
+ if (argv.format === 'json') {
58
+ const thresholds = {};
59
+ for (const [key, stats] of Object.entries(groups)) {
60
+ thresholds[key] = {
61
+ p95_baseline: stats.p95,
62
+ threshold_ms: Math.round(stats.p95 * multiplier),
63
+ count: stats.count
64
+ };
65
+ }
66
+ result = JSON.stringify({ thresholds }, null, 2);
67
+ } else {
68
+ result = emitThresholds(groups, multiplier);
69
+ }
70
+
71
+ if (argv.explain && process.env.ANTHROPIC_API_KEY) {
72
+ const { annotate } = await import('./explain.js');
73
+ result = await annotate(result, groups);
74
+ }
75
+
76
+ if (argv.output) {
77
+ writeFileSync(argv.output, result, 'utf8');
78
+ process.stderr.write(`Written to ${argv.output}\n`);
79
+ } else {
80
+ process.stdout.write(result);
81
+ }
82
+ } catch (err) {
83
+ process.stderr.write(`Error: ${err.message}\n`);
84
+ process.exit(1);
85
+ }
package/src/emitter.js ADDED
@@ -0,0 +1,31 @@
1
+ // src/emitter.js
2
+
3
+ /**
4
+ * Emit a k6 options object containing a thresholds block derived from group stats.
5
+ * @param {Record<string, {count,p95,...}>} groups - output from computeGroups
6
+ * @param {number} multiplier - applied to p95 to set the threshold (default 1.5)
7
+ * @returns {string} valid k6 JS string
8
+ */
9
+ export function emitThresholds(groups, multiplier = 1.5) {
10
+ const lines = [
11
+ '// Auto-generated by har-to-slo',
12
+ '// https://github.com/aks-builds/har-to-slo',
13
+ '',
14
+ 'export const options = {',
15
+ ' thresholds: {',
16
+ ];
17
+
18
+ for (const [key, stats] of Object.entries(groups)) {
19
+ const limit = Math.round(stats.p95 * multiplier);
20
+ lines.push(` // ${key} — p95 baseline: ${stats.p95}ms (n=${stats.count}, ×${multiplier})`);
21
+ lines.push(` "http_req_duration{scenario:${sanitiseKey(key)}}": ["p(95)<${limit}"],`);
22
+ }
23
+
24
+ lines.push(' },', '};', '');
25
+ return lines.join('\n');
26
+ }
27
+
28
+ /** Convert a route key like 'GET /users/{id}' into a safe k6 tag value. */
29
+ function sanitiseKey(key) {
30
+ return key.replace(/[^a-zA-Z0-9_/-]/g, '_');
31
+ }
package/src/explain.js ADDED
@@ -0,0 +1,32 @@
1
+ // src/explain.js
2
+ import Anthropic from '@anthropic-ai/sdk';
3
+
4
+ /**
5
+ * Annotate k6 threshold output with plain-English rationale from Claude Haiku.
6
+ * @param {string} thresholdOutput - the JS string from emitThresholds
7
+ * @param {Record<string, object>} groups - from computeGroups
8
+ * @returns {Promise<string>}
9
+ */
10
+ export async function annotate(thresholdOutput, groups) {
11
+ const client = new Anthropic();
12
+ const groupSummary = Object.entries(groups)
13
+ .map(([key, s]) => `${key}: p95=${s.p95}ms, count=${s.count}`)
14
+ .join('\n');
15
+
16
+ const message = await client.messages.create({
17
+ model: 'claude-haiku-4-5',
18
+ max_tokens: 1024,
19
+ messages: [{
20
+ role: 'user',
21
+ content: `You are a performance engineering assistant. Below are k6 thresholds derived from real HAR file timing data. Add a one-line JS comment above each threshold explaining why this SLO value makes sense given the baseline data. Keep comments under 100 chars each. Return ONLY the modified JS with your comments inserted — no prose, no markdown.
22
+
23
+ Route baselines:
24
+ ${groupSummary}
25
+
26
+ Current thresholds output:
27
+ ${thresholdOutput}`
28
+ }]
29
+ });
30
+
31
+ return message.content[0].type === 'text' ? message.content[0].text : thresholdOutput;
32
+ }
package/src/parser.js ADDED
@@ -0,0 +1,36 @@
1
+ // src/parser.js
2
+ import { readFile } from 'node:fs/promises';
3
+
4
+ /**
5
+ * Parse an in-memory HAR object into a flat array of request entries.
6
+ * Skips entries that have no `time` field.
7
+ * @param {object} har - parsed HAR JSON
8
+ * @returns {{ method: string, url: string, status: number, time: number }[]}
9
+ */
10
+ export function parseHar(har) {
11
+ return (har?.log?.entries ?? [])
12
+ .filter(e => typeof e.time === 'number' && !isNaN(e.time))
13
+ .map(e => {
14
+ if (!e.request?.method || !e.response?.status) return null;
15
+ return {
16
+ method: e.request.method.toUpperCase(),
17
+ url: e.request.url,
18
+ status: e.response.status,
19
+ time: e.time
20
+ };
21
+ })
22
+ .filter(Boolean);
23
+ }
24
+
25
+ /**
26
+ * Read a HAR file from disk and return parsed entries.
27
+ * @param {string} filePath - absolute or relative path to .har file
28
+ */
29
+ export async function parseHarFile(filePath) {
30
+ try {
31
+ const raw = await readFile(filePath, 'utf8');
32
+ return parseHar(JSON.parse(raw));
33
+ } catch (err) {
34
+ throw new Error(`Failed to read HAR file at "${filePath}": ${err.message}`);
35
+ }
36
+ }
package/src/routes.js ADDED
@@ -0,0 +1,19 @@
1
+ // src/routes.js
2
+
3
+ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
4
+ const HEX_RE = /(\/)[0-9a-f]{16,}(?=\/|$)/gi;
5
+ const NUM_RE = /\/\d+(?=\/|$)/g;
6
+
7
+ /**
8
+ * Collapse a full URL into a normalised route template.
9
+ * Returns only the path portion with parameterised segments replaced.
10
+ * @param {string} url
11
+ * @returns {string} e.g. '/users/{id}/orders/{uuid}'
12
+ */
13
+ export function collapseUrl(url) {
14
+ const { pathname } = new URL(url);
15
+ return pathname
16
+ .replace(UUID_RE, '{uuid}')
17
+ .replace(HEX_RE, '/{hash}')
18
+ .replace(NUM_RE, '/{id}');
19
+ }
package/src/stats.js ADDED
@@ -0,0 +1,42 @@
1
+ // src/stats.js
2
+
3
+ /**
4
+ * Compute the p-th percentile of a pre-sorted array.
5
+ */
6
+ export function percentile(sorted, p) {
7
+ if (sorted.length === 0) return 0;
8
+ const idx = Math.ceil((p / 100) * sorted.length) - 1;
9
+ return sorted[Math.max(0, Math.min(idx, sorted.length - 1))];
10
+ }
11
+
12
+ /**
13
+ * Group HAR entries by method+template, trim top 5% outliers, compute stats.
14
+ */
15
+ export function computeGroups(entries, collapser) {
16
+ const buckets = {};
17
+
18
+ for (const entry of entries) {
19
+ const template = collapser(entry.url);
20
+ const key = `${entry.method} ${template}`;
21
+ (buckets[key] ??= []).push(entry.time);
22
+ }
23
+
24
+ const groups = {};
25
+ for (const [key, times] of Object.entries(buckets)) {
26
+ const sorted = [...times].sort((a, b) => a - b);
27
+ // Trim top 5% outliers for min/max display only
28
+ const trimTo = Math.max(1, Math.floor(sorted.length * 0.95));
29
+ const trimmed = sorted.slice(0, trimTo);
30
+
31
+ groups[key] = {
32
+ count: times.length,
33
+ min: trimmed[0],
34
+ max: trimmed[trimmed.length - 1],
35
+ p50: percentile(sorted, 50), // percentiles on FULL data
36
+ p75: percentile(sorted, 75),
37
+ p95: percentile(sorted, 95),
38
+ p99: percentile(sorted, 99),
39
+ };
40
+ }
41
+ return groups;
42
+ }