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 +10 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/package.json +47 -0
- package/skills/har-to-slo.md +57 -0
- package/src/cli.js +85 -0
- package/src/emitter.js +31 -0
- package/src/explain.js +32 -0
- package/src/parser.js +36 -0
- package/src/routes.js +19 -0
- package/src/stats.js +42 -0
package/CHANGELOG.md
ADDED
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
|
+
[](https://github.com/aks-builds/har-to-slo/actions/workflows/ci.yml)
|
|
17
|
+
[](https://github.com/aks-builds/har-to-slo/actions/workflows/codeql.yml)
|
|
18
|
+
[](https://www.npmjs.com/package/har-to-slo)
|
|
19
|
+
[](LICENSE)
|
|
20
|
+
[](skills/har-to-slo.md)
|
|
21
|
+
|
|
22
|
+
<br/>
|
|
23
|
+
|
|
24
|
+

|
|
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
|
+
}
|