measure-fn 3.5.0 → 3.7.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 +167 -207
- package/SKILL.md +12 -4
- package/banner.png +0 -0
- package/index.test.ts +76 -39
- package/index.ts +41 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,302 +1,262 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="banner.png" alt="measure-fn" width="100%" />
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<p align="center">
|
|
6
|
+
<b>Replace try-catch + timing boilerplate in TypeScript with a single line of code.</b>
|
|
7
|
+
</p>
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
[18:50:04.895] [e-b] ... Fetch User (userId=2)
|
|
11
|
-
[18:50:04.950] [e-b] ✓ Fetch User 55.58ms → {"id":2,"name":"User 2"}
|
|
12
|
-
[18:50:04.981] [e-a] ✓ Fetch User 85.93ms → {"id":1,"name":"User 1"}
|
|
13
|
-
[18:50:04.981] [e] ✓ Parallel Fetch 86.08ms
|
|
14
|
-
[18:50:05.072] [f] ✓ DB query 91.12ms → {"rows":42} ⚠ OVER BUDGET (30.00ms)
|
|
15
|
-
[18:50:05.775] [m] ... Fetch all users (20 items)
|
|
16
|
-
[18:50:06.179] [m] = 5/20 (0.4s, 12/s)
|
|
17
|
-
[18:50:07.450] [m] ✓ Fetch all users (20 items) 1.7s → "20/20 ok"
|
|
18
|
-
[18:50:07.450] [api:a] ... GET /users
|
|
19
|
-
[18:50:07.450] [db:a] ... SELECT users
|
|
20
|
-
[18:50:07.493] [db:a] ✓ SELECT users 43.07ms → [{"id":1},{"id":2}]
|
|
21
|
-
[18:50:07.493] [api:a] ✓ GET /users 43.32ms → [{"id":1},{"id":2}]
|
|
22
|
-
[18:50:08.721] [o] ✓ Slow op 1.2s → "slow"
|
|
23
|
-
```
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://www.npmjs.com/package/measure-fn"><img src="https://img.shields.io/npm/v/measure-fn.svg" alt="npm version"></a>
|
|
11
|
+
<a href="https://www.npmjs.com/package/measure-fn"><img src="https://img.shields.io/npm/dm/measure-fn.svg" alt="npm downloads"></a>
|
|
12
|
+
<a href="https://github.com/7flash/measure-fn/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License"></a>
|
|
13
|
+
</p>
|
|
24
14
|
|
|
25
|
-
|
|
15
|
+
Whenever a function needs error handling so it doesn't crash, and timing so you know how long it took, you usually end up adding this boilerplate manually:
|
|
26
16
|
|
|
27
|
-
|
|
17
|
+
**Before:**
|
|
28
18
|
|
|
29
|
-
```
|
|
30
|
-
|
|
19
|
+
```typescript
|
|
20
|
+
let users = null;
|
|
21
|
+
try {
|
|
22
|
+
const start = performance.now();
|
|
23
|
+
users = await fetchUsers();
|
|
24
|
+
const ms = (performance.now() - start).toFixed(2);
|
|
25
|
+
console.log(`[a] ··········· ${ms}ms → ${JSON.stringify(users)}`);
|
|
26
|
+
} catch (e) {
|
|
27
|
+
console.log(`[a] ✗ Fetch users (${e.message})`);
|
|
28
|
+
console.error(e.stack);
|
|
29
|
+
}
|
|
31
30
|
```
|
|
32
31
|
|
|
33
|
-
|
|
32
|
+
**After:** measure-fn does the exact same thing in one line. Completely type-safe (infers `T | null`) and never crashes.
|
|
34
33
|
|
|
35
34
|
```typescript
|
|
36
|
-
import { measure
|
|
35
|
+
import { measure } from 'measure-fn';
|
|
37
36
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
// → [a] ✓ Parse config 0.20ms → {"port":3000}
|
|
41
|
-
|
|
42
|
-
// Async — start + end
|
|
43
|
-
const data = await measure('Fetch data', async () => {
|
|
44
|
-
return await fetch(url).then(r => r.json());
|
|
45
|
-
});
|
|
46
|
-
// → [b] ... Fetch data
|
|
47
|
-
// → [b] ✓ Fetch data 245.12ms → [{"id":1}]
|
|
37
|
+
const users = await measure('Fetch users', () => fetchUsers());
|
|
38
|
+
// → [a] ··········· 86ms → [{"id":1},{"id":2}]
|
|
48
39
|
```
|
|
49
40
|
|
|
50
|
-
##
|
|
41
|
+
## Installation
|
|
51
42
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
| `[id] ✗ label Nms (err)` | Error | `[a] ✗ Fetch 2ms (timeout)` |
|
|
57
|
-
| `[id] = label` | Annotation | `[a] = checkpoint` |
|
|
43
|
+
```sh
|
|
44
|
+
npm install measure-fn
|
|
45
|
+
# or bun add / pnpm add / yarn add
|
|
46
|
+
```
|
|
58
47
|
|
|
59
|
-
|
|
48
|
+
## ✨ Defaults
|
|
60
49
|
|
|
61
|
-
|
|
50
|
+
Every `measure` call automatically:
|
|
62
51
|
|
|
63
|
-
|
|
52
|
+
- 🛡️ **Catches errors** → logs `✗` with a stack trace and returns `null` (no unhandled rejections)
|
|
53
|
+
- ⏱️ **Logs timing** → prints `label Nms → result` using `performance.now()`
|
|
54
|
+
- 🌳 **Assigns a trace ID** → `[a]`, `[b]`, `[a-a]` for zero-config nested hierarchy
|
|
64
55
|
|
|
65
|
-
|
|
56
|
+
## 🌳 Nested Calls (Tracing)
|
|
66
57
|
|
|
67
|
-
|
|
68
|
-
// Simple
|
|
69
|
-
const user = await measure('Fetch user', () => fetchUser(1));
|
|
58
|
+
Pass a child `m` function to get hierarchical APM-like tracing for free:
|
|
70
59
|
|
|
71
|
-
|
|
60
|
+
```typescript
|
|
72
61
|
await measure('Pipeline', async (m) => {
|
|
73
|
-
await
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
]);
|
|
62
|
+
const user = await m('Fetch user', () => fetchUser(1));
|
|
63
|
+
const posts = await m('Fetch posts', () => fetchPosts(user.id));
|
|
64
|
+
return posts;
|
|
77
65
|
});
|
|
66
|
+
```
|
|
78
67
|
|
|
79
|
-
|
|
80
|
-
|
|
68
|
+
```
|
|
69
|
+
[a] ... Pipeline
|
|
70
|
+
[a-a] ·········· 82ms → {"id":1}
|
|
71
|
+
[a-b] ··········· 45ms → [...]
|
|
72
|
+
[a] ········ 128ms
|
|
81
73
|
```
|
|
82
74
|
|
|
83
|
-
|
|
75
|
+
Parallel execution works cleanly too:
|
|
84
76
|
|
|
85
77
|
```typescript
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const data = m('Parse', () => parse(raw));
|
|
92
|
-
return m('Summarize', () => summarize(data));
|
|
78
|
+
await measure('Load all', async (m) => {
|
|
79
|
+
const [users, posts] = await Promise.all([
|
|
80
|
+
m('Users', () => fetchUsers()),
|
|
81
|
+
m('Posts', () => fetchPosts()),
|
|
82
|
+
]);
|
|
93
83
|
});
|
|
94
84
|
```
|
|
95
85
|
|
|
96
|
-
|
|
86
|
+
## 🛡️ Error Handling
|
|
97
87
|
|
|
98
|
-
|
|
88
|
+
By default, errors return `null` so your pipelines can continue safely:
|
|
99
89
|
|
|
100
90
|
```typescript
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
await getUser(2); // → [b] ... Get user → [b] ✓ Get user 75ms → {...}
|
|
91
|
+
const user = await measure('Fetch user', () => fetchUser(1));
|
|
92
|
+
// If it throws → logs ✗, user = null
|
|
104
93
|
```
|
|
105
94
|
|
|
106
|
-
|
|
95
|
+
**Custom Fallbacks:** Pass `onError` as the 3rd argument:
|
|
107
96
|
|
|
108
97
|
```typescript
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
Output:
|
|
114
|
-
```
|
|
115
|
-
[a] ... Process users (500 items)
|
|
116
|
-
[a] = 100/500 (1.2s, 83/s)
|
|
117
|
-
[a] = 200/500 (2.1s, 95/s)
|
|
118
|
-
[a] ✓ Process users (500 items) 5.3s → "500/500 ok"
|
|
98
|
+
const user = await measure('Fetch user', () => fetchUser(1),
|
|
99
|
+
(error) => defaultUser
|
|
100
|
+
);
|
|
101
|
+
// If it throws → logs ✗, user = defaultUser
|
|
119
102
|
```
|
|
120
103
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
```typescript
|
|
124
|
-
const result = await measure.retry('Flaky API', {
|
|
125
|
-
attempts: 3, delay: 1000, backoff: 2
|
|
126
|
-
}, () => fetchFlakyApi());
|
|
127
|
-
```
|
|
128
|
-
```
|
|
129
|
-
[a] ... Flaky API [1/3]
|
|
130
|
-
[a] ✗ Flaky API [1/3] 102ms (timeout)
|
|
131
|
-
[b] ... Flaky API [2/3]
|
|
132
|
-
[b] ✓ Flaky API [2/3] 89ms → {"status":"ok"}
|
|
133
|
-
```
|
|
104
|
+
If the `onError` fallback itself throws, that's also safely caught and returns `null`. measure never crashes.
|
|
134
105
|
|
|
135
|
-
|
|
106
|
+
**Fail-Fast (`.assert`):** Use `.assert()` when you need a guaranteed non-null result:
|
|
136
107
|
|
|
137
108
|
```typescript
|
|
138
109
|
const user = await measure.assert('Get user', () => fetchUser(1));
|
|
139
|
-
//
|
|
110
|
+
// If it throws → logs ✗, re-throws with .cause = original error
|
|
140
111
|
```
|
|
141
112
|
|
|
142
|
-
|
|
113
|
+
| Pattern | On error | Return Type |
|
|
114
|
+
|---------|----------|-------------|
|
|
115
|
+
| `measure(label, fn)` | returns `null` | `T \| null` |
|
|
116
|
+
| `measure(label, fn, onError)` | returns `onError(error)` | `T` |
|
|
117
|
+
| `measure.assert(label, fn)` | throws with `.cause` | `T` |
|
|
143
118
|
|
|
144
|
-
|
|
145
|
-
await measure({ label: 'DB query', budget: 100 }, async () => {
|
|
146
|
-
return await db.query('SELECT * FROM users');
|
|
147
|
-
});
|
|
148
|
-
// → [a] ✓ DB query 245ms → [...] ⚠ OVER BUDGET (100ms)
|
|
149
|
-
```
|
|
119
|
+
## 🚦 Timeouts & Budgets
|
|
150
120
|
|
|
151
|
-
|
|
121
|
+
The first argument can be a label string, or an options object:
|
|
152
122
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
123
|
+
| Field | Type | Effect |
|
|
124
|
+
|-------|------|--------|
|
|
125
|
+
| `label` | `string` | Display name (required if object) |
|
|
126
|
+
| `timeout` | `number` | Aborts after N ms (returns `null`) |
|
|
127
|
+
| `budget` | `number` | Warns if slower than N ms (doesn't abort) |
|
|
128
|
+
| any other | `any` | Logged inline as context metadata |
|
|
156
129
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
// →
|
|
162
|
-
// → [api:a] ✓ GET /users 45ms → [...]
|
|
130
|
+
**Timeout** (enforce):
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
const data = await measure({ label: 'Slow API', timeout: 5000 }, () => fetchSlowApi());
|
|
134
|
+
// > 5s → ✗ Slow API 5.0s (Timeout (5.0s)), returns null
|
|
163
135
|
```
|
|
164
136
|
|
|
165
|
-
|
|
137
|
+
Works with `onError` fallback too.
|
|
166
138
|
|
|
167
|
-
|
|
139
|
+
**Budget** (warn):
|
|
168
140
|
|
|
169
141
|
```typescript
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
fetch: (req) => measure.assert('Handle', async () => {
|
|
173
|
-
return new Response('ok');
|
|
174
|
-
}),
|
|
175
|
-
error: () => new Response('Internal Server Error', { status: 500 }),
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
// ✅ Nullish coalescing — graceful 500 fallback
|
|
179
|
-
Bun.serve({
|
|
180
|
-
fetch: async (req) => {
|
|
181
|
-
return (await measure('Handle', async () => {
|
|
182
|
-
return new Response('ok');
|
|
183
|
-
})) ?? new Response('Internal Server Error', { status: 500 });
|
|
184
|
-
},
|
|
185
|
-
});
|
|
142
|
+
await measure({ label: 'DB query', budget: 100 }, () => db.query('...'));
|
|
143
|
+
// → [a] ········ 245ms → [...] ⚠ OVER BUDGET (100ms)
|
|
186
144
|
```
|
|
187
145
|
|
|
188
|
-
|
|
146
|
+
Combine both — budget warns early, timeout enforces a hard stop:
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
await measure({ label: 'Query', budget: 100, timeout: 5000 }, () => query());
|
|
150
|
+
```
|
|
189
151
|
|
|
190
|
-
|
|
152
|
+
**Metadata context:**
|
|
191
153
|
|
|
192
154
|
```typescript
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
timestamps: true, // prepend [HH:MM:SS.mmm]
|
|
196
|
-
maxResultLength: 200, // truncate results (default: 80)
|
|
197
|
-
logger: (event) => { // custom event handler
|
|
198
|
-
myTelemetry.track(event);
|
|
199
|
-
}
|
|
200
|
-
});
|
|
155
|
+
await measure({ label: 'Fetch user', userId: 1 }, () => fetchUser(1));
|
|
156
|
+
// → [a] ... Fetch user (userId=1)
|
|
201
157
|
```
|
|
202
158
|
|
|
203
|
-
|
|
159
|
+
## 🧰 Extensions
|
|
204
160
|
|
|
205
|
-
### `measure.
|
|
161
|
+
### `measure.wrap(label, fn)`
|
|
162
|
+
|
|
163
|
+
Wrap a function once, measure every time it's called:
|
|
206
164
|
|
|
207
165
|
```typescript
|
|
208
|
-
const
|
|
166
|
+
const getUser = measure.wrap('Get user', fetchUser);
|
|
167
|
+
await getUser(1); // → [a] ········ 82ms
|
|
168
|
+
await getUser(2); // → [b] ········ 75ms
|
|
209
169
|
```
|
|
210
170
|
|
|
211
|
-
###
|
|
171
|
+
### `measure.batch(label, items, fn, opts?)`
|
|
212
172
|
|
|
213
|
-
|
|
214
|
-
import { safeStringify, formatDuration, resetCounter } from 'measure-fn';
|
|
173
|
+
Process arrays with built-in progress logs:
|
|
215
174
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
175
|
+
```typescript
|
|
176
|
+
const results = await measure.batch('Process', userIds, async (id) => {
|
|
177
|
+
return await processUser(id);
|
|
178
|
+
}, { every: 100 });
|
|
179
|
+
// → [a] ... Process (500 items)
|
|
180
|
+
// → [a] = 100/500 (1.2s, 83/s)
|
|
181
|
+
// → [a] ················· 5.3s → "500/500 ok"
|
|
219
182
|
```
|
|
220
183
|
|
|
221
|
-
|
|
184
|
+
### `measure.retry(label, opts, fn)`
|
|
222
185
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
**When you need to handle the error**, pass an `onError` handler as the 3rd argument. It receives the original error and its return value replaces `null`:
|
|
186
|
+
Automatic retries with delay and backoff:
|
|
226
187
|
|
|
227
188
|
```typescript
|
|
228
|
-
|
|
229
|
-
|
|
189
|
+
const result = await measure.retry('Flaky API', {
|
|
190
|
+
attempts: 3, delay: 1000, backoff: 2
|
|
191
|
+
}, () => fetchFlakyApi());
|
|
192
|
+
// → [a] ✗ Flaky API [1/3] 102ms (timeout)
|
|
193
|
+
// → [b] ················· 89ms → {"status":"ok"}
|
|
194
|
+
```
|
|
230
195
|
|
|
231
|
-
|
|
232
|
-
const user = await measure('Fetch user', () => fetchUser(1),
|
|
233
|
-
(error) => defaultUser
|
|
234
|
-
);
|
|
196
|
+
### `measure.timed(label, fn?)`
|
|
235
197
|
|
|
236
|
-
|
|
237
|
-
const user = await measure('Fetch user', () => fetchUser(1),
|
|
238
|
-
(error) => {
|
|
239
|
-
if (error instanceof NotFoundError) return guestUser;
|
|
240
|
-
if (error instanceof NetworkError) return cachedUser;
|
|
241
|
-
throw error; // unexpected — propagates up
|
|
242
|
-
}
|
|
243
|
-
);
|
|
198
|
+
Get duration programmatically alongside the result:
|
|
244
199
|
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
(error) => { throw error }
|
|
248
|
-
);
|
|
200
|
+
```typescript
|
|
201
|
+
const { result, duration } = await measure.timed('Fetch', () => fetchUsers());
|
|
249
202
|
```
|
|
250
203
|
|
|
251
|
-
|
|
204
|
+
### `createMeasure(prefix)`
|
|
205
|
+
|
|
206
|
+
Scoped instances with custom prefixes:
|
|
252
207
|
|
|
253
208
|
```typescript
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
),
|
|
209
|
+
const api = createMeasure('api');
|
|
210
|
+
const db = createMeasure('db');
|
|
211
|
+
|
|
212
|
+
await api.measure('GET /users', async () => {
|
|
213
|
+
return await db.measure('SELECT', () => query('...'));
|
|
260
214
|
});
|
|
215
|
+
// → [api:a] ... GET /users
|
|
216
|
+
// → [db:a] ······ 44ms
|
|
217
|
+
// → [api:a] ·········· 45ms
|
|
261
218
|
```
|
|
262
219
|
|
|
263
|
-
|
|
220
|
+
### Annotations & Sync
|
|
264
221
|
|
|
265
222
|
```typescript
|
|
266
|
-
|
|
267
|
-
await measure.assert('Op', () => work());
|
|
268
|
-
await measure('Op', () => work(), (e) => { throw e });
|
|
223
|
+
import { measureSync } from 'measure-fn';
|
|
269
224
|
|
|
270
|
-
|
|
271
|
-
// e.message → 'measure.assert: "Op" failed'
|
|
272
|
-
// e.cause → original error
|
|
273
|
-
```
|
|
274
|
-
|
|
275
|
-
**Summary:**
|
|
225
|
+
const config = measureSync('Parse config', () => JSON.parse(raw));
|
|
276
226
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
| `measure(label, fn, onError)` | logs `✗`, calls `onError(error)` | Recovery, fallbacks, error inspection |
|
|
281
|
-
| `measure.assert(label, fn)` | logs `✗`, throws with `.cause` | Must have non-null |
|
|
227
|
+
await measure('Server ready');
|
|
228
|
+
// → [a] = Server ready
|
|
229
|
+
```
|
|
282
230
|
|
|
283
|
-
##
|
|
231
|
+
## ⚙️ Configuration
|
|
284
232
|
|
|
285
233
|
```typescript
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
234
|
+
import { configure } from 'measure-fn';
|
|
235
|
+
|
|
236
|
+
configure({
|
|
237
|
+
silent: true, // suppress all output
|
|
238
|
+
timestamps: true, // prepend [HH:MM:SS.mmm]
|
|
239
|
+
maxResultLength: 200, // truncate results (default: 80)
|
|
240
|
+
dotEndLabel: false, // show full label on end lines (default: true = dots)
|
|
241
|
+
dotChar: '.', // character for dot fill (default: '·')
|
|
242
|
+
logger: (event) => { // custom event handler
|
|
243
|
+
myTelemetry.track(event);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
295
246
|
```
|
|
296
247
|
|
|
297
|
-
|
|
248
|
+
Env vars: `MEASURE_SILENT=1`, `MEASURE_TIMESTAMPS=1`
|
|
249
|
+
|
|
250
|
+
## Output Format
|
|
251
|
+
|
|
252
|
+
| Symbol | Meaning | Example |
|
|
253
|
+
|--------|---------|---------|
|
|
254
|
+
| `...` | Started | `[a] ... Fetch users` |
|
|
255
|
+
| `···` | Success | `[a] ··········· 86ms → [...]` |
|
|
256
|
+
| `✗` | Error | `[a] ✗ ··········· (Network Error)` |
|
|
257
|
+
| `=` | Annotation | `[a] = Server ready` |
|
|
298
258
|
|
|
299
|
-
|
|
259
|
+
IDs encode hierarchy: `[a]` → root, `[a-a]` → first child, `[a-b]` → second child.
|
|
300
260
|
|
|
301
261
|
## License
|
|
302
262
|
|
package/SKILL.md
CHANGED
|
@@ -104,11 +104,19 @@ const result = await measure.retry('External API', {
|
|
|
104
104
|
}, () => callExternalService());
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
-
### 7. Budget warnings
|
|
107
|
+
### 7. Budget warnings and timeouts
|
|
108
108
|
|
|
109
109
|
```typescript
|
|
110
|
+
// Budget: warns but doesn't stop
|
|
110
111
|
await measure({ label: 'DB query', budget: 100 }, () => heavyQuery());
|
|
111
112
|
// → [a] ✓ DB query 245ms → [...] ⚠ OVER BUDGET (100ms)
|
|
113
|
+
|
|
114
|
+
// Timeout: aborts after N ms, returns null
|
|
115
|
+
await measure({ label: 'External API', timeout: 5000 }, () => fetchSlowApi());
|
|
116
|
+
// > 5s → [a] ✗ External API 5.0s (Timeout (5.0s))
|
|
117
|
+
|
|
118
|
+
// Both together: budget warns, timeout enforces
|
|
119
|
+
await measure({ label: 'Query', budget: 100, timeout: 5000 }, () => db.query('...'));
|
|
112
120
|
```
|
|
113
121
|
|
|
114
122
|
### 8. Assert non-null results
|
|
@@ -170,11 +178,11 @@ Bun.serve({
|
|
|
170
178
|
});
|
|
171
179
|
```
|
|
172
180
|
|
|
173
|
-
`.assert()`
|
|
181
|
+
`.assert()` re-throws on error with `.cause` = original error:
|
|
174
182
|
|
|
175
183
|
```typescript
|
|
176
184
|
await measure.assert('Op', () => work());
|
|
177
|
-
//
|
|
185
|
+
// throws: Error('measure.assert: "Op" failed', { cause: originalError })
|
|
178
186
|
```
|
|
179
187
|
|
|
180
188
|
## Error Model
|
|
@@ -230,7 +238,7 @@ await measure('Outer', async (m) => {
|
|
|
230
238
|
|
|
231
239
|
| Export | Use |
|
|
232
240
|
|--------|-----|
|
|
233
|
-
| `measure(label, fn?)` | Async measurement |
|
|
241
|
+
| `measure(label, fn?, onError?)` | Async measurement (onError handles expected errors) |
|
|
234
242
|
| `measureSync(label, fn?)` | Sync measurement |
|
|
235
243
|
| `measure.wrap(label, fn)` | Decorator — wrap once, measure every call |
|
|
236
244
|
| `measure.batch(label, items, fn, opts?)` | Array + progress |
|
package/banner.png
ADDED
|
Binary file
|
package/index.test.ts
CHANGED
|
@@ -31,12 +31,12 @@ describe("measure (async)", () => {
|
|
|
31
31
|
expect(result).toBe(42);
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
test("logs start ... and success
|
|
34
|
+
test("logs start ... and success with result", async () => {
|
|
35
35
|
const out = captureConsole();
|
|
36
36
|
await measure("fetch", async () => "ok");
|
|
37
37
|
out.restore();
|
|
38
38
|
expect(out.logs[0]).toBe("[a] ... fetch");
|
|
39
|
-
expect(out.logs[1]).toMatch(/\[a\]
|
|
39
|
+
expect(out.logs[1]).toMatch(/\[a\] ····· .* → "ok"/);
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
test("no arrow for undefined", async () => {
|
|
@@ -123,7 +123,7 @@ describe("measureSync", () => {
|
|
|
123
123
|
measureSync("compute", () => 100);
|
|
124
124
|
out.restore();
|
|
125
125
|
expect(out.logs.length).toBe(1);
|
|
126
|
-
expect(out.logs[0]).toMatch(/\[a\]
|
|
126
|
+
expect(out.logs[0]).toMatch(/\[a\] ······· .* → 100/);
|
|
127
127
|
});
|
|
128
128
|
|
|
129
129
|
test("with children = start + end", () => {
|
|
@@ -131,8 +131,8 @@ describe("measureSync", () => {
|
|
|
131
131
|
measureSync("parent", (m) => { m("child", () => 10); return 20; });
|
|
132
132
|
out.restore();
|
|
133
133
|
expect(out.logs[0]).toBe("[a] ... parent");
|
|
134
|
-
expect(out.logs[1]).toMatch(/\[a-a\]
|
|
135
|
-
expect(out.logs[2]).toMatch(/\[a\]
|
|
134
|
+
expect(out.logs[1]).toMatch(/\[a-a\] ····· .* → 10/);
|
|
135
|
+
expect(out.logs[2]).toMatch(/\[a\] ······ .* → 20/);
|
|
136
136
|
});
|
|
137
137
|
|
|
138
138
|
test("error returns null", () => {
|
|
@@ -354,7 +354,7 @@ describe("measure.retry", () => {
|
|
|
354
354
|
const r = await measure.retry("op", { attempts: 3, delay: 10 }, async () => 42);
|
|
355
355
|
out.restore();
|
|
356
356
|
expect(r).toBe(42);
|
|
357
|
-
expect(out.logs[1]).toContain("
|
|
357
|
+
expect(out.logs[1]).not.toContain("✗");
|
|
358
358
|
expect(out.logs[0]).toContain("[1/3]");
|
|
359
359
|
});
|
|
360
360
|
|
|
@@ -368,7 +368,7 @@ describe("measure.retry", () => {
|
|
|
368
368
|
out.restore();
|
|
369
369
|
expect(r).toBe("ok");
|
|
370
370
|
expect(out.logs.filter(l => l.includes("✗")).length).toBe(2);
|
|
371
|
-
expect(out.logs.filter(l => l.includes("
|
|
371
|
+
expect(out.logs.filter(l => !l.includes("✗") && !l.includes("...")).length).toBe(1);
|
|
372
372
|
});
|
|
373
373
|
|
|
374
374
|
test("all attempts exhausted returns null", async () => {
|
|
@@ -463,16 +463,11 @@ describe("onError (3rd argument)", () => {
|
|
|
463
463
|
expect(captured).toBe(original);
|
|
464
464
|
});
|
|
465
465
|
|
|
466
|
-
test("onError
|
|
466
|
+
test("onError rethrow returns null (caught by safety net)", async () => {
|
|
467
467
|
const out = captureConsole();
|
|
468
|
-
const
|
|
469
|
-
try {
|
|
470
|
-
await measure('Op', async () => { throw original; }, (e) => { throw e; });
|
|
471
|
-
expect(true).toBe(false);
|
|
472
|
-
} catch (e) {
|
|
473
|
-
expect(e).toBe(original);
|
|
474
|
-
}
|
|
468
|
+
const result = await measure('Op', async () => { throw new Error('critical'); }, (e) => { throw e; });
|
|
475
469
|
out.restore();
|
|
470
|
+
expect(result).toBeNull();
|
|
476
471
|
});
|
|
477
472
|
|
|
478
473
|
test("onError can inspect error type and recover", async () => {
|
|
@@ -487,27 +482,6 @@ describe("onError (3rd argument)", () => {
|
|
|
487
482
|
expect(result).toBe('recovered');
|
|
488
483
|
});
|
|
489
484
|
|
|
490
|
-
test("sync onError returns fallback", () => {
|
|
491
|
-
const out = captureConsole();
|
|
492
|
-
const result = measureSync('Parse', () => {
|
|
493
|
-
throw new Error('bad input');
|
|
494
|
-
}, () => 'default');
|
|
495
|
-
out.restore();
|
|
496
|
-
expect(result).toBe('default');
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
test("sync onError receives error", () => {
|
|
500
|
-
const out = captureConsole();
|
|
501
|
-
const original = new Error('sync fail');
|
|
502
|
-
let captured: unknown = null;
|
|
503
|
-
measureSync('Op', () => { throw original; }, (err) => {
|
|
504
|
-
captured = err;
|
|
505
|
-
return null;
|
|
506
|
-
});
|
|
507
|
-
out.restore();
|
|
508
|
-
expect(captured).toBe(original);
|
|
509
|
-
});
|
|
510
|
-
|
|
511
485
|
test("still logs error even when onError handles it", async () => {
|
|
512
486
|
const events: any[] = [];
|
|
513
487
|
configure({ logger: (e) => events.push(e) });
|
|
@@ -533,8 +507,72 @@ describe("onError (3rd argument)", () => {
|
|
|
533
507
|
expect(result!.status).toBe(500);
|
|
534
508
|
expect(await result!.text()).toContain('route error');
|
|
535
509
|
});
|
|
510
|
+
|
|
511
|
+
test("if onError itself throws, returns null instead of crashing", async () => {
|
|
512
|
+
const out = captureConsole();
|
|
513
|
+
const result = await measure('Primary DB', async () => {
|
|
514
|
+
throw new Error('primary failed');
|
|
515
|
+
}, (error) => {
|
|
516
|
+
// fallback DB call also fails
|
|
517
|
+
throw new Error('backup DB also failed');
|
|
518
|
+
});
|
|
519
|
+
out.restore();
|
|
520
|
+
expect(result).toBeNull();
|
|
521
|
+
// should log both errors
|
|
522
|
+
expect(out.errors.some(l => l.includes('primary failed'))).toBe(true);
|
|
523
|
+
expect(out.errors.some(l => l.includes('backup DB also failed'))).toBe(true);
|
|
524
|
+
});
|
|
536
525
|
});
|
|
537
526
|
|
|
527
|
+
// ─── timeout ─────────────────────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
describe("timeout", () => {
|
|
530
|
+
beforeEach(() => {
|
|
531
|
+
resetCounter();
|
|
532
|
+
configure({ silent: false, logger: null, timestamps: false });
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("aborts and returns null if function exceeds timeout", async () => {
|
|
536
|
+
const out = captureConsole();
|
|
537
|
+
const result = await measure(
|
|
538
|
+
{ label: 'Slow op', timeout: 50 },
|
|
539
|
+
async () => {
|
|
540
|
+
await new Promise(r => setTimeout(r, 200));
|
|
541
|
+
return 'done';
|
|
542
|
+
}
|
|
543
|
+
);
|
|
544
|
+
out.restore();
|
|
545
|
+
expect(result).toBeNull();
|
|
546
|
+
expect(out.errors.some(l => l.includes('Timeout'))).toBe(true);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("succeeds if function completes within timeout", async () => {
|
|
550
|
+
const out = captureConsole();
|
|
551
|
+
const result = await measure(
|
|
552
|
+
{ label: 'Fast op', timeout: 200 },
|
|
553
|
+
async () => {
|
|
554
|
+
await new Promise(r => setTimeout(r, 10));
|
|
555
|
+
return 'done';
|
|
556
|
+
}
|
|
557
|
+
);
|
|
558
|
+
out.restore();
|
|
559
|
+
expect(result).toBe('done');
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("timeout with onError returns fallback", async () => {
|
|
563
|
+
const out = captureConsole();
|
|
564
|
+
const result = await measure(
|
|
565
|
+
{ label: 'Slow op', timeout: 50 },
|
|
566
|
+
async () => {
|
|
567
|
+
await new Promise(r => setTimeout(r, 200));
|
|
568
|
+
return 'done';
|
|
569
|
+
},
|
|
570
|
+
(error) => 'timed-out'
|
|
571
|
+
);
|
|
572
|
+
out.restore();
|
|
573
|
+
expect(result).toBe('timed-out');
|
|
574
|
+
});
|
|
575
|
+
});
|
|
538
576
|
// ─── measure.wrap ────────────────────────────────────────────────────
|
|
539
577
|
|
|
540
578
|
describe("measure.wrap", () => {
|
|
@@ -551,7 +589,7 @@ describe("measure.wrap", () => {
|
|
|
551
589
|
out.restore();
|
|
552
590
|
expect(r).toBe(42);
|
|
553
591
|
expect(out.logs[0]).toBe("[a] ... double");
|
|
554
|
-
expect(out.logs[1]).toContain("
|
|
592
|
+
expect(out.logs[1]).not.toContain("✗");
|
|
555
593
|
});
|
|
556
594
|
|
|
557
595
|
test("multiple calls get sequential IDs", async () => {
|
|
@@ -572,7 +610,7 @@ describe("measure.wrap", () => {
|
|
|
572
610
|
const r = wrapped(7);
|
|
573
611
|
out.restore();
|
|
574
612
|
expect(r).toBe(21);
|
|
575
|
-
expect(out.logs[0]).toContain("
|
|
613
|
+
expect(out.logs[0]).toContain("······");
|
|
576
614
|
});
|
|
577
615
|
});
|
|
578
616
|
|
|
@@ -590,7 +628,6 @@ describe("measure.batch", () => {
|
|
|
590
628
|
out.restore();
|
|
591
629
|
expect(results).toEqual([2, 4, 6]);
|
|
592
630
|
expect(out.logs[0]).toContain("3 items");
|
|
593
|
-
expect(out.logs.at(-1)).toContain("✓");
|
|
594
631
|
expect(out.logs.at(-1)).toContain("3/3 ok");
|
|
595
632
|
});
|
|
596
633
|
|
package/index.ts
CHANGED
|
@@ -54,7 +54,7 @@ const formatDuration = (ms: number): string => {
|
|
|
54
54
|
// ─── Timestamps ──────────────────────────────────────────────────────
|
|
55
55
|
|
|
56
56
|
let timestamps =
|
|
57
|
-
process.env.MEASURE_TIMESTAMPS === '1' || process.env.MEASURE_TIMESTAMPS === 'true';
|
|
57
|
+
typeof process !== 'undefined' && (process.env.MEASURE_TIMESTAMPS === '1' || process.env.MEASURE_TIMESTAMPS === 'true');
|
|
58
58
|
|
|
59
59
|
const ts = (): string => {
|
|
60
60
|
if (!timestamps) return '';
|
|
@@ -83,7 +83,10 @@ export type MeasureEvent = {
|
|
|
83
83
|
// ─── Configuration ───────────────────────────────────────────────────
|
|
84
84
|
|
|
85
85
|
export let silent =
|
|
86
|
-
process.env.MEASURE_SILENT === '1' || process.env.MEASURE_SILENT === 'true';
|
|
86
|
+
typeof process !== 'undefined' && (process.env.MEASURE_SILENT === '1' || process.env.MEASURE_SILENT === 'true');
|
|
87
|
+
|
|
88
|
+
let dotEndLabel = true;
|
|
89
|
+
let dotChar = '·';
|
|
87
90
|
|
|
88
91
|
export let logger: ((event: MeasureEvent) => void) | null = null;
|
|
89
92
|
|
|
@@ -92,6 +95,8 @@ export type ConfigureOpts = {
|
|
|
92
95
|
logger?: ((event: MeasureEvent) => void) | null;
|
|
93
96
|
timestamps?: boolean;
|
|
94
97
|
maxResultLength?: number;
|
|
98
|
+
dotEndLabel?: boolean;
|
|
99
|
+
dotChar?: string;
|
|
95
100
|
};
|
|
96
101
|
|
|
97
102
|
export const configure = (opts: ConfigureOpts) => {
|
|
@@ -99,6 +104,8 @@ export const configure = (opts: ConfigureOpts) => {
|
|
|
99
104
|
if (opts.logger !== undefined) logger = opts.logger;
|
|
100
105
|
if (opts.timestamps !== undefined) timestamps = opts.timestamps;
|
|
101
106
|
if (opts.maxResultLength !== undefined) maxResultLen = opts.maxResultLength;
|
|
107
|
+
if (opts.dotEndLabel !== undefined) dotEndLabel = opts.dotEndLabel;
|
|
108
|
+
if (opts.dotChar !== undefined) dotChar = opts.dotChar;
|
|
102
109
|
};
|
|
103
110
|
|
|
104
111
|
// ─── Shared Helpers ──────────────────────────────────────────────────
|
|
@@ -115,6 +122,12 @@ const extractBudget = (actionInternal: string | object): number | undefined => {
|
|
|
115
122
|
return undefined;
|
|
116
123
|
};
|
|
117
124
|
|
|
125
|
+
const extractTimeout = (actionInternal: string | object): number | undefined => {
|
|
126
|
+
if (typeof actionInternal !== 'object' || actionInternal === null) return undefined;
|
|
127
|
+
if ('timeout' in actionInternal) return Number((actionInternal as any).timeout);
|
|
128
|
+
return undefined;
|
|
129
|
+
};
|
|
130
|
+
|
|
118
131
|
const extractMeta = (actionInternal: string | object): Record<string, unknown> | undefined => {
|
|
119
132
|
if (typeof actionInternal !== 'object' || actionInternal === null) return undefined;
|
|
120
133
|
const details = { ...actionInternal };
|
|
@@ -151,20 +164,22 @@ const defaultLogger = (event: MeasureEvent, prefix?: string) => {
|
|
|
151
164
|
console.log(`${t}${id} ... ${event.label}${formatMeta(event.meta)}`);
|
|
152
165
|
break;
|
|
153
166
|
case 'success': {
|
|
167
|
+
const endLabel = dotEndLabel ? dotChar.repeat(event.label.length) : event.label;
|
|
154
168
|
const resultStr = event.result !== undefined ? safeStringify(event.result) : '';
|
|
155
169
|
const arrow = resultStr ? ` → ${resultStr}` : '';
|
|
156
170
|
const budgetWarn = event.budget && event.duration! > event.budget
|
|
157
171
|
? ` ⚠ OVER BUDGET (${formatDuration(event.budget)})`
|
|
158
172
|
: '';
|
|
159
|
-
console.log(`${t}${id}
|
|
173
|
+
console.log(`${t}${id} ${endLabel} ${formatDuration(event.duration!)}${arrow}${budgetWarn}`);
|
|
160
174
|
break;
|
|
161
175
|
}
|
|
162
176
|
case 'error': {
|
|
177
|
+
const endLabel = dotEndLabel ? dotChar.repeat(event.label.length) : event.label;
|
|
163
178
|
const errorMsg = event.error instanceof Error ? event.error.message : String(event.error);
|
|
164
179
|
const budgetWarn = event.budget && event.duration! > event.budget
|
|
165
180
|
? ` ⚠ OVER BUDGET (${formatDuration(event.budget)})`
|
|
166
181
|
: '';
|
|
167
|
-
console.log(`${t}${id} ✗ ${
|
|
182
|
+
console.log(`${t}${id} ✗ ${endLabel} ${formatDuration(event.duration!)} (${errorMsg})${budgetWarn}`);
|
|
168
183
|
if (event.error instanceof Error) {
|
|
169
184
|
console.error(`${id}`, event.error.stack ?? event.error.message);
|
|
170
185
|
if (event.error.cause) {
|
|
@@ -263,6 +278,7 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
263
278
|
const childCounterRef = { value: 0 };
|
|
264
279
|
const label = buildActionLabel(actionInternal);
|
|
265
280
|
const budget = extractBudget(actionInternal);
|
|
281
|
+
const timeout = extractTimeout(actionInternal);
|
|
266
282
|
|
|
267
283
|
const currentId = toAlpha(parentIdChain.pop() ?? 0);
|
|
268
284
|
const fullIdChain = [...parentIdChain, currentId];
|
|
@@ -279,7 +295,17 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
279
295
|
const measureForNextLevel = createNestedResolver(true, fullIdChain, childCounterRef, depth, _measureInternal, prefix);
|
|
280
296
|
|
|
281
297
|
try {
|
|
282
|
-
|
|
298
|
+
let result: U;
|
|
299
|
+
if (timeout && timeout > 0) {
|
|
300
|
+
result = await Promise.race([
|
|
301
|
+
fnInternal(measureForNextLevel as MeasureFn),
|
|
302
|
+
new Promise<never>((_, reject) =>
|
|
303
|
+
setTimeout(() => reject(new Error(`Timeout (${formatDuration(timeout)})`)), timeout)
|
|
304
|
+
),
|
|
305
|
+
]);
|
|
306
|
+
} else {
|
|
307
|
+
result = await fnInternal(measureForNextLevel as MeasureFn);
|
|
308
|
+
}
|
|
283
309
|
const duration = performance.now() - start;
|
|
284
310
|
emit({ type: 'success', id: idStr, label, depth, duration, result, budget }, prefix);
|
|
285
311
|
return result;
|
|
@@ -287,7 +313,15 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
287
313
|
const duration = performance.now() - start;
|
|
288
314
|
emit({ type: 'error', id: idStr, label, depth, duration, error, budget }, prefix);
|
|
289
315
|
_lastError = error;
|
|
290
|
-
if (onError)
|
|
316
|
+
if (onError) {
|
|
317
|
+
try {
|
|
318
|
+
return onError(error);
|
|
319
|
+
} catch (onErrorError) {
|
|
320
|
+
emit({ type: 'error', id: idStr, label: `${label} (onError)`, depth, duration: performance.now() - start, error: onErrorError, budget }, prefix);
|
|
321
|
+
_lastError = onErrorError;
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
291
325
|
return null;
|
|
292
326
|
}
|
|
293
327
|
};
|
|
@@ -296,8 +330,7 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
296
330
|
fnInternal: (measure: MeasureSyncFn) => U,
|
|
297
331
|
actionInternal: string | object,
|
|
298
332
|
parentIdChain: (string | number)[],
|
|
299
|
-
depth: number
|
|
300
|
-
onError?: (error: unknown) => any
|
|
333
|
+
depth: number
|
|
301
334
|
): U | null => {
|
|
302
335
|
const start = performance.now();
|
|
303
336
|
const childCounterRef = { value: 0 };
|
|
@@ -330,7 +363,6 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
|
|
|
330
363
|
const duration = performance.now() - start;
|
|
331
364
|
emit({ type: 'error', id: idStr, label, depth, duration, error, budget }, prefix);
|
|
332
365
|
_lastError = error;
|
|
333
|
-
if (onError) return onError(error);
|
|
334
366
|
return null;
|
|
335
367
|
}
|
|
336
368
|
};
|
package/package.json
CHANGED