measure-fn 3.5.0 → 3.6.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 CHANGED
@@ -1,302 +1,229 @@
1
- # measure-fn
1
+ <p align="center">
2
+ <img src="banner.png" alt="measure-fn" width="100%" />
3
+ </p>
2
4
 
3
- **Stop writing blind code.** Every function you write either succeeds or fails, takes some amount of time, and lives inside a larger flow. `measure-fn` makes all of that visible — automatically, hierarchically.
4
-
5
- ```
6
- [18:50:04.893] [a] ✓ Load config 0.09ms → {"env":"prod","port":3000}
7
- [18:50:04.894] [b] = App ready
8
- [18:50:04.895] [e] ... Parallel Fetch
9
- [18:50:04.895] [e-a] ... Fetch User (userId=1)
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
- ```
24
-
25
- No setup. No dashboards. Just wrap your functions.
26
-
27
- ## Install
5
+ Wrap functions with automatic try-catch, timing, timeouts, and structured logging.
28
6
 
29
7
  ```sh
30
8
  bun add measure-fn
31
9
  ```
32
10
 
33
- ## Quick Start
34
-
35
11
  ```typescript
36
12
  import { measure, measureSync } from 'measure-fn';
37
13
 
38
- // Sync leaf single line with auto-printed result
39
- const config = measureSync('Parse config', () => JSON.parse(str));
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}]
14
+ const users = await measure('Fetch users', () => fetchUsers());
15
+ const config = measureSync('Parse config', () => JSON.parse(raw));
48
16
  ```
49
17
 
50
- ## Output Format
51
-
52
- | Pattern | When | Example |
53
- |---------|------|---------|
54
- | `[id] ... label` | Async start / sync with children | `[a] ... Pipeline` |
55
- | `[id] ✓ label Nms → value` | Success | `[a] ✓ Fetch 102ms → {"id":1}` |
56
- | `[id] ✗ label Nms (err)` | Error | `[a] ✗ Fetch 2ms (timeout)` |
57
- | `[id] = label` | Annotation | `[a] = checkpoint` |
18
+ ```
19
+ [a] ... Fetch users
20
+ [a] Fetch users 86ms [{"id":1},{"id":2}]
21
+ [b] ✓ Parse config 0.09ms → {"env":"prod","port":3000}
22
+ ```
58
23
 
59
- **No indentation, no colors.** IDs encode hierarchy. Return values auto-print. Circular refs → `[Circular]`, long values truncated.
24
+ ## Defaults
60
25
 
61
- **Smart duration**: `0.10ms` → `1.2s` → `2m 5s`
26
+ Every `measure` call:
27
+ - **catches errors** → logs `✗` with stack trace, returns `null` (no unhandled rejections)
28
+ - **logs timing** → `✓ label Nms → result`
29
+ - **assigns an ID** → `[a]`, `[b]`, `[a-a]` for nested calls
62
30
 
63
- ## API
31
+ ## Error Handling
64
32
 
65
- ### `measure(label, fn?)` async
33
+ By default, errors return `null`:
66
34
 
67
35
  ```typescript
68
- // Simple
69
36
  const user = await measure('Fetch user', () => fetchUser(1));
70
-
71
- // Nested + parallel
72
- await measure('Pipeline', async (m) => {
73
- await Promise.all([
74
- m({ label: 'Fetch', userId: 1 }, () => fetchUser(1)),
75
- m({ label: 'Fetch', userId: 2 }, () => fetchUser(2)),
76
- ]);
77
- });
78
-
79
- // Annotation
80
- await measure('checkpoint');
37
+ // throws → logs ✗, user = null
81
38
  ```
82
39
 
83
- ### `measureSync(label, fn?)` synchronous
40
+ Pass `onError` as 3rd argument to provide a fallback:
84
41
 
85
42
  ```typescript
86
- // Leaf single line
87
- const hash = measureSync('Hash', () => computeHash(data));
88
-
89
- // With children start + end
90
- measureSync('Report', (m) => {
91
- const data = m('Parse', () => parse(raw));
92
- return m('Summarize', () => summarize(data));
93
- });
43
+ const user = await measure('Fetch user', () => fetchUser(1),
44
+ (error) => defaultUser
45
+ );
46
+ // throws logs ✗, user = defaultUser
94
47
  ```
95
48
 
96
- ### `measure.wrap(label, fn)`decorator
49
+ If `onError` itself throws, that's also caught returns `null`. Measure never crashes.
97
50
 
98
- Wrap a function once, every call is measured:
51
+ Use `.assert()` when you need a guaranteed non-null result:
99
52
 
100
53
  ```typescript
101
- const getUser = measure.wrap('Get user', fetchUser);
102
- await getUser(1); // [a] ... Get user [a] Get user 82ms → {...}
103
- await getUser(2); // → [b] ... Get user → [b] ✓ Get user 75ms → {...}
54
+ const user = await measure.assert('Get user', () => fetchUser(1));
55
+ // throwslogs ✗, re-throws with .cause = original error
104
56
  ```
105
57
 
106
- ### `measure.batch(label, items, fn, opts?)` — array processing with progress
58
+ | Pattern | On error |
59
+ |---------|----------|
60
+ | `measure(label, fn)` | returns `null` |
61
+ | `measure(label, fn, onError)` | returns `onError(error)` |
62
+ | `measure.assert(label, fn)` | throws with `.cause` |
107
63
 
108
- ```typescript
109
- const results = await measure.batch('Process users', userIds, async (id) => {
110
- return await processUser(id);
111
- }, { every: 100 }); // log progress every 100 items
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"
119
- ```
64
+ ## Timeout
120
65
 
121
- ### `measure.retry(label, opts, fn)` retry with backoff
66
+ Set `timeout` in the label object to abort after N milliseconds:
122
67
 
123
68
  ```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"}
69
+ const data = await measure({ label: 'Slow API', timeout: 5000 }, () => fetchSlowApi());
70
+ // > 5s → ✗ Slow API 5.0s (Timeout (5.0s)), returns null
133
71
  ```
134
72
 
135
- ### `measure.assert(label, fn)` — throw if null
73
+ Works with `onError`:
136
74
 
137
75
  ```typescript
138
- const user = await measure.assert('Get user', () => fetchUser(1));
139
- // guaranteed non-null, or throws
76
+ const data = await measure({ label: 'Slow API', timeout: 5000 }, () => fetchSlowApi(),
77
+ (error) => cachedData
78
+ );
140
79
  ```
141
80
 
142
- ### Budget — warn on slow operations
81
+ ## Budget
82
+
83
+ Set `budget` to log a warning when a call exceeds the expected time (doesn't abort):
143
84
 
144
85
  ```typescript
145
- await measure({ label: 'DB query', budget: 100 }, async () => {
146
- return await db.query('SELECT * FROM users');
147
- });
86
+ await measure({ label: 'DB query', budget: 100 }, () => db.query('...'));
148
87
  // → [a] ✓ DB query 245ms → [...] ⚠ OVER BUDGET (100ms)
149
88
  ```
150
89
 
151
- ### `createMeasure(prefix)`scoped instances
90
+ Combine bothbudget warns, timeout enforces:
152
91
 
153
92
  ```typescript
154
- const api = createMeasure('api');
155
- const db = createMeasure('db');
156
-
157
- await api.measure('GET /users', async () => {
158
- return await db.measure('SELECT', () => query('...'));
159
- });
160
- // → [api:a] ... GET /users
161
- // → [db:a] ✓ SELECT 44ms → [...]
162
- // → [api:a] ✓ GET /users 45ms → [...]
93
+ await measure({ label: 'Query', budget: 100, timeout: 5000 }, () => query());
163
94
  ```
164
95
 
165
- ### Bun.serve — handling Response
96
+ ## Nested Calls
166
97
 
167
- `measure()` returns `null` on error instead of throwing. In `Bun.serve`, the fetch handler **must** return a `Response` — returning `null` crashes. Two solutions:
98
+ Pass a child `m` to create hierarchy:
168
99
 
169
100
  ```typescript
170
- // measure.assert — throws on error, use with Bun.serve error handler
171
- Bun.serve({
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
- },
101
+ await measure('Pipeline', async (m) => {
102
+ const user = await m('Fetch user', () => fetchUser(1));
103
+ const posts = await m('Fetch posts', () => fetchPosts(user.id));
104
+ return posts;
185
105
  });
186
106
  ```
187
107
 
188
- > **Why not plain `measure()`?** On error it returns `null`, not a `Response`. This is by design — `measure` never throws (except `.assert()`).
108
+ ```
109
+ [a] ... Pipeline
110
+ [a-a] ✓ Fetch user 82ms → {"id":1}
111
+ [a-b] ✓ Fetch posts 45ms → [...]
112
+ [a] ✓ Pipeline 128ms
113
+ ```
189
114
 
190
- ### `configure(opts)` — runtime config
115
+ Parallel:
191
116
 
192
117
  ```typescript
193
- configure({
194
- silent: true, // suppress all output
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
- }
118
+ await measure('Load all', async (m) => {
119
+ const [users, posts] = await Promise.all([
120
+ m('Users', () => fetchUsers()),
121
+ m('Posts', () => fetchPosts()),
122
+ ]);
200
123
  });
201
124
  ```
202
125
 
203
- Env: `MEASURE_SILENT=1`, `MEASURE_TIMESTAMPS=1`
126
+ ## Label Object
127
+
128
+ The first argument can be a string or an object. Object fields:
204
129
 
205
- ### `measure.timed(label, fn?)` programmatic timing
130
+ | Field | Type | Effect |
131
+ |-------|------|--------|
132
+ | `label` | `string` | Display name (required if object) |
133
+ | `timeout` | `number` | Abort after N ms |
134
+ | `budget` | `number` | Warn if slower than N ms |
135
+ | any other | `any` | Logged as metadata |
206
136
 
207
137
  ```typescript
208
- const { result, duration } = await measure.timed('Fetch', () => fetchUsers());
138
+ await measure({ label: 'Fetch user', userId: 1, timeout: 3000 }, () => fetchUser(1));
139
+ // → [a] ... Fetch user (userId=1)
209
140
  ```
210
141
 
211
- ### Utilities
142
+ ## Extensions
212
143
 
213
- ```typescript
214
- import { safeStringify, formatDuration, resetCounter } from 'measure-fn';
144
+ ### `measure.wrap(label, fn)` — wrap once, measure every call
215
145
 
216
- safeStringify({ circular: self }); // handles circular refs, truncates
217
- formatDuration(91234); // "1m 31s"
218
- resetCounter(); // reset ID counter for tests
146
+ ```typescript
147
+ const getUser = measure.wrap('Get user', fetchUser);
148
+ await getUser(1); // [a] Get user 82ms
149
+ await getUser(2); // → [b] ✓ Get user 75ms
219
150
  ```
220
151
 
221
- ## Error Handling
222
-
223
- `measure` never throws. On error it logs `✗` with timing and stack trace, then returns `null`. This keeps pipelines resilient — one failing step doesn't crash the rest.
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`:
152
+ ### `measure.batch(label, items, fn, opts?)` — array with progress
226
153
 
227
154
  ```typescript
228
- // Default: returns null on error
229
- const user = await measure('Fetch user', () => fetchUser(1));
230
-
231
- // With recovery: returns fallback on error
232
- const user = await measure('Fetch user', () => fetchUser(1),
233
- (error) => defaultUser
234
- );
155
+ const results = await measure.batch('Process', userIds, async (id) => {
156
+ return await processUser(id);
157
+ }, { every: 100 });
158
+ // [a] ... Process (500 items)
159
+ // [a] = 100/500 (1.2s, 83/s)
160
+ // → [a] ✓ Process (500 items) 5.3s → "500/500 ok"
161
+ ```
235
162
 
236
- // With error inspection: handle known errors, rethrow unknown
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
- );
163
+ ### `measure.retry(label, opts, fn)` retry with backoff
244
164
 
245
- // Rethrow all: transparent observability (same as .assert())
246
- const user = await measure('Fetch user', () => fetchUser(1),
247
- (error) => { throw error }
248
- );
165
+ ```typescript
166
+ const result = await measure.retry('Flaky API', {
167
+ attempts: 3, delay: 1000, backoff: 2
168
+ }, () => fetchFlakyApi());
169
+ // → [a] ✗ Flaky API [1/3] 102ms (timeout)
170
+ // → [b] ✓ Flaky API [2/3] 89ms → {"status":"ok"}
249
171
  ```
250
172
 
251
- **Bun.servenever return null:**
173
+ ### `measure.timed(label, fn?)` get duration programmatically
252
174
 
253
175
  ```typescript
254
- Bun.serve({
255
- fetch: (req) => measure(
256
- { label: `${req.method} ${req.url}` },
257
- () => handleRequest(req),
258
- (error) => new Response(`Error: ${error.message}`, { status: 500 })
259
- ),
260
- });
176
+ const { result, duration } = await measure.timed('Fetch', () => fetchUsers());
261
177
  ```
262
178
 
263
- **`.assert()` is sugar for the rethrow pattern:**
179
+ ### `createMeasure(prefix)` scoped instance
264
180
 
265
181
  ```typescript
266
- // These are equivalent:
267
- await measure.assert('Op', () => work());
268
- await measure('Op', () => work(), (e) => { throw e });
182
+ const api = createMeasure('api');
183
+ const db = createMeasure('db');
269
184
 
270
- // .assert() wraps the error with .cause for inspection:
271
- // e.message 'measure.assert: "Op" failed'
272
- // e.cause → original error
185
+ await api.measure('GET /users', async () => {
186
+ return await db.measure('SELECT', () => query('...'));
187
+ });
188
+ // → [api:a] ... GET /users
189
+ // → [db:a] ✓ SELECT 44ms
190
+ // → [api:a] ✓ GET /users 45ms
273
191
  ```
274
192
 
275
- **Summary:**
193
+ ### Annotations
276
194
 
277
- | Pattern | On error | Use when |
278
- |---------|----------|----------|
279
- | `measure(label, fn)` | logs `✗`, returns `null` | Default — pipeline resilience |
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 |
195
+ ```typescript
196
+ await measure('Server ready');
197
+ // [a] = Server ready
198
+ ```
282
199
 
283
- ## Types
200
+ ## Configure
284
201
 
285
202
  ```typescript
286
- export type MeasureEvent = {
287
- type: 'start' | 'success' | 'error' | 'annotation';
288
- id: string; label: string; depth: number;
289
- duration?: number; result?: unknown; error?: unknown;
290
- meta?: Record<string, unknown>; budget?: number;
291
- };
292
- export type TimedResult<T> = { result: T | null; duration: number };
293
- export type RetryOpts = { attempts?: number; delay?: number; backoff?: number };
294
- export type BatchOpts = { every?: number };
203
+ import { configure } from 'measure-fn';
204
+
205
+ configure({
206
+ silent: true, // suppress all output
207
+ timestamps: true, // prepend [HH:MM:SS.mmm]
208
+ maxResultLength: 200, // truncate results (default: 80)
209
+ logger: (event) => { // custom event handler
210
+ myTelemetry.track(event);
211
+ }
212
+ });
295
213
  ```
296
214
 
297
- ## Zero Dependencies
215
+ Env vars: `MEASURE_SILENT=1`, `MEASURE_TIMESTAMPS=1`
216
+
217
+ ## Output Format
218
+
219
+ | Symbol | Meaning |
220
+ |--------|---------|
221
+ | `[a] ...` | started |
222
+ | `[a] ✓` | success |
223
+ | `[a] ✗` | error |
224
+ | `[a] =` | annotation |
298
225
 
299
- Works in Bun, Node, Deno. Uses only `performance.now()` and `console`.
226
+ IDs encode hierarchy: `[a]` → root, `[a-a]` first child, `[a-b]` second child.
300
227
 
301
228
  ## License
302
229
 
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 for slow ops
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()` is sugar for `(e) => { throw e }` with `.cause`:
181
+ `.assert()` re-throws on error with `.cause` = original error:
174
182
 
175
183
  ```typescript
176
184
  await measure.assert('Op', () => work());
177
- // equivalent to: measure('Op', () => work(), (e) => { throw e })
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
@@ -463,16 +463,11 @@ describe("onError (3rd argument)", () => {
463
463
  expect(captured).toBe(original);
464
464
  });
465
465
 
466
- test("onError can rethrow (same as assert)", async () => {
466
+ test("onError rethrow returns null (caught by safety net)", async () => {
467
467
  const out = captureConsole();
468
- const original = new Error('critical');
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", () => {
package/index.ts CHANGED
@@ -115,6 +115,12 @@ const extractBudget = (actionInternal: string | object): number | undefined => {
115
115
  return undefined;
116
116
  };
117
117
 
118
+ const extractTimeout = (actionInternal: string | object): number | undefined => {
119
+ if (typeof actionInternal !== 'object' || actionInternal === null) return undefined;
120
+ if ('timeout' in actionInternal) return Number((actionInternal as any).timeout);
121
+ return undefined;
122
+ };
123
+
118
124
  const extractMeta = (actionInternal: string | object): Record<string, unknown> | undefined => {
119
125
  if (typeof actionInternal !== 'object' || actionInternal === null) return undefined;
120
126
  const details = { ...actionInternal };
@@ -263,6 +269,7 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
263
269
  const childCounterRef = { value: 0 };
264
270
  const label = buildActionLabel(actionInternal);
265
271
  const budget = extractBudget(actionInternal);
272
+ const timeout = extractTimeout(actionInternal);
266
273
 
267
274
  const currentId = toAlpha(parentIdChain.pop() ?? 0);
268
275
  const fullIdChain = [...parentIdChain, currentId];
@@ -279,7 +286,17 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
279
286
  const measureForNextLevel = createNestedResolver(true, fullIdChain, childCounterRef, depth, _measureInternal, prefix);
280
287
 
281
288
  try {
282
- const result = await fnInternal(measureForNextLevel as MeasureFn);
289
+ let result: U;
290
+ if (timeout && timeout > 0) {
291
+ result = await Promise.race([
292
+ fnInternal(measureForNextLevel as MeasureFn),
293
+ new Promise<never>((_, reject) =>
294
+ setTimeout(() => reject(new Error(`Timeout (${formatDuration(timeout)})`)), timeout)
295
+ ),
296
+ ]);
297
+ } else {
298
+ result = await fnInternal(measureForNextLevel as MeasureFn);
299
+ }
283
300
  const duration = performance.now() - start;
284
301
  emit({ type: 'success', id: idStr, label, depth, duration, result, budget }, prefix);
285
302
  return result;
@@ -287,7 +304,15 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
287
304
  const duration = performance.now() - start;
288
305
  emit({ type: 'error', id: idStr, label, depth, duration, error, budget }, prefix);
289
306
  _lastError = error;
290
- if (onError) return onError(error);
307
+ if (onError) {
308
+ try {
309
+ return onError(error);
310
+ } catch (onErrorError) {
311
+ emit({ type: 'error', id: idStr, label: `${label} (onError)`, depth, duration: performance.now() - start, error: onErrorError, budget }, prefix);
312
+ _lastError = onErrorError;
313
+ return null;
314
+ }
315
+ }
291
316
  return null;
292
317
  }
293
318
  };
@@ -296,8 +321,7 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
296
321
  fnInternal: (measure: MeasureSyncFn) => U,
297
322
  actionInternal: string | object,
298
323
  parentIdChain: (string | number)[],
299
- depth: number,
300
- onError?: (error: unknown) => any
324
+ depth: number
301
325
  ): U | null => {
302
326
  const start = performance.now();
303
327
  const childCounterRef = { value: 0 };
@@ -330,7 +354,6 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
330
354
  const duration = performance.now() - start;
331
355
  emit({ type: 'error', id: idStr, label, depth, duration, error, budget }, prefix);
332
356
  _lastError = error;
333
- if (onError) return onError(error);
334
357
  return null;
335
358
  }
336
359
  };
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "module": "index.ts",
4
4
  "main": "./index.ts",
5
5
  "types": "./index.ts",
6
- "version": "3.5.0",
6
+ "version": "3.6.0",
7
7
  "type": "module",
8
8
  "private": false,
9
9
  "description": "Zero-dependency function performance measurement with hierarchical logging",
@@ -30,4 +30,4 @@
30
30
  "test": "bun test",
31
31
  "example": "bun run example.ts"
32
32
  }
33
- }
33
+ }