measure-fn 3.3.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,296 +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
- ## Why `measure` Never Throws
152
+ ### `measure.batch(label, items, fn, opts?)` array with progress
222
153
 
223
- This is a deliberate design choice, not a shortcut.
154
+ ```typescript
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
+ ```
224
162
 
225
- **Measure is observability, not error handling.** It answers "what happened, how long did it take, did it fail?" not "how should I recover from this specific error?" That's your job, inside the callback.
163
+ ### `measure.retry(label, opts, fn)`retry with backoff
226
164
 
227
- **The Go inspiration (and where it differs):** Go's `result, err := doSomething()` treats errors as values. `measure` takes the same philosophy — errors don't crash your pipeline — but it intentionally discards the error *to the caller*. Why? Because every error is **already logged** with `✗`, timing, stack trace, and cause chain. There are no silent failures. The error is visible, just not returned.
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"}
171
+ ```
228
172
 
229
- **Handle recoverable errors inside, let measure catch the rest:**
173
+ ### `measure.timed(label, fn?)` get duration programmatically
230
174
 
231
175
  ```typescript
232
- const user = await measure('Fetch user', async () => {
233
- try {
234
- return await fetchUser(1);
235
- } catch (e) {
236
- if (e instanceof NetworkError) {
237
- return await fetchFromCache(1); // recover from known error
238
- }
239
- throw e; // unexpected — measure catches, logs ✗, returns null
240
- }
241
- });
176
+ const { result, duration } = await measure.timed('Fetch', () => fetchUsers());
242
177
  ```
243
178
 
244
- This is the correct mental model: **try/catch inside for errors you know how to handle**, `null` outside for everything unexpected. You don't wrap measure in try/catch you put recovery logic *within* it.
245
-
246
- **When you need the result to be non-null:**
179
+ ### `createMeasure(prefix)` — scoped instance
247
180
 
248
181
  ```typescript
249
- // .assert() — re-throws, guarantees non-null
250
- const user = await measure.assert('Get user', () => fetchUser(1));
182
+ const api = createMeasure('api');
183
+ const db = createMeasure('db');
251
184
 
252
- // ?? graceful fallback
253
- const user = (await measure('Get user', () => fetchUser(1))) ?? defaultUser;
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
254
191
  ```
255
192
 
256
- **Summary:**
193
+ ### Annotations
257
194
 
258
- | Method | On error | Use when |
259
- |--------|----------|----------|
260
- | `measure()` | logs `✗`, returns `null` | Default — pipeline keeps running |
261
- | `try/catch` inside | you handle it | Recoverable errors (network, retries) |
262
- | `measure.assert()` | logs `✗`, then throws (`.cause` = original error) | Must have non-null (Bun.serve, etc.) |
263
- | `?? fallback` | returns fallback | Graceful degradation |
195
+ ```typescript
196
+ await measure('Server ready');
197
+ // [a] = Server ready
198
+ ```
264
199
 
265
- **Watch out: layered architectures.** When you have `measure → handler → handler's own try/catch`, a `null` return is ambiguous — did the handler catch the error and return a valid fallback, or did measure catch it and swallow it? If your handler has its own error handling, use `measure.assert()` instead of `measure()` so measure stays out of the error path:
200
+ ## Configure
266
201
 
267
202
  ```typescript
268
- // Ambiguous: did handler or measure catch the error?
269
- const response = await measure('Handle', () => handler(req)); // null = ???
203
+ import { configure } from 'measure-fn';
270
204
 
271
- // ✓ Clear: measure observes, handler handles its own errors
272
- const response = await measure.assert('Handle', () => handler(req));
273
- // handler's try/catch runs first → returns error Response
274
- // measure only catches if handler ITSELF throws (unexpected)
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
+ });
275
213
  ```
276
214
 
277
- ## Types
215
+ Env vars: `MEASURE_SILENT=1`, `MEASURE_TIMESTAMPS=1`
278
216
 
279
- ```typescript
280
- export type MeasureEvent = {
281
- type: 'start' | 'success' | 'error' | 'annotation';
282
- id: string; label: string; depth: number;
283
- duration?: number; result?: unknown; error?: unknown;
284
- meta?: Record<string, unknown>; budget?: number;
285
- };
286
- export type TimedResult<T> = { result: T | null; duration: number };
287
- export type RetryOpts = { attempts?: number; delay?: number; backoff?: number };
288
- export type BatchOpts = { every?: number };
289
- ```
217
+ ## Output Format
290
218
 
291
- ## Zero Dependencies
219
+ | Symbol | Meaning |
220
+ |--------|---------|
221
+ | `[a] ...` | started |
222
+ | `[a] ✓` | success |
223
+ | `[a] ✗` | error |
224
+ | `[a] =` | annotation |
292
225
 
293
- 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.
294
227
 
295
228
  ## License
296
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
@@ -139,29 +147,52 @@ await measure('Server ready'); // → [a] = Server ready
139
147
  measureSync('Config loaded'); // → [b] = Config loaded
140
148
  ```
141
149
 
142
- ### 11. Bun.servealways use `measure.assert` or fallback
150
+ ### 11. Error handling — `onError` 3rd argument
143
151
 
144
- `measure()` returns `null` on error. In a fetch handler, you **must** return a `Response`:
152
+ `measure` never throws. Pass an `onError` handler as 3rd argument to handle errors:
145
153
 
146
154
  ```typescript
147
- // measure.assert — throws on error, pair with Bun.serve error handler
155
+ // Default: null on error
156
+ const user = await measure('Fetch user', () => fetchUser(1));
157
+
158
+ // Recovery: fallback on error
159
+ const user = await measure('Fetch user', () => fetchUser(1),
160
+ (error) => defaultUser
161
+ );
162
+
163
+ // Error inspection: handle known errors, rethrow unknown
164
+ const user = await measure('Fetch user', () => fetchUser(1),
165
+ (error) => {
166
+ if (error instanceof NetworkError) return cachedUser;
167
+ throw error;
168
+ }
169
+ );
170
+
171
+ // Bun.serve: always return a Response
148
172
  Bun.serve({
149
- fetch: (req) => measure.assert('Handle', async () => {
150
- return new Response('ok');
151
- }),
152
- error: () => new Response('Internal Server Error', { status: 500 }),
173
+ fetch: (req) => measure(
174
+ { label: `${req.method} ${req.url}` },
175
+ () => handleRequest(req),
176
+ (error) => new Response('Internal Server Error', { status: 500 })
177
+ ),
153
178
  });
179
+ ```
154
180
 
155
- // Nullish coalescing fallback
156
- Bun.serve({
157
- fetch: async (req) => {
158
- return (await measure('Handle', async () => {
159
- return new Response('ok');
160
- })) ?? new Response('Internal Server Error', { status: 500 });
161
- },
162
- });
181
+ `.assert()` re-throws on error with `.cause` = original error:
182
+
183
+ ```typescript
184
+ await measure.assert('Op', () => work());
185
+ // throws: Error('measure.assert: "Op" failed', { cause: originalError })
163
186
  ```
164
187
 
188
+ ## Error Model
189
+
190
+ | Pattern | On error | Use when |
191
+ |---------|----------|----------|
192
+ | `measure(label, fn)` | logs `✗`, returns `null` | Default — pipeline resilience |
193
+ | `measure(label, fn, onError)` | logs `✗`, calls `onError(error)` | Recovery, fallbacks, error inspection |
194
+ | `measure.assert(label, fn)` | logs `✗`, throws with `.cause` | Must have non-null |
195
+
165
196
  ## Configuration
166
197
 
167
198
  ```typescript
@@ -186,16 +217,6 @@ const { result, duration } = await measure.timed('Fetch', () => fetchUsers());
186
217
  if (duration > 1000) alert('Slow!');
187
218
  ```
188
219
 
189
- ## Error Model — measure Never Throws
190
-
191
- Like Go's `result, err` pattern, `measure` treats errors as values: returns `null` on failure, **always** logs the error with timing and stack trace. One failing step doesn't crash the pipeline.
192
-
193
- | Method | On error | Use when |
194
- |--------|----------|----------|
195
- | `measure()` | returns `null` | Default — resilient pipelines |
196
- | `measure.assert()` | throws | Must have non-null (e.g. Bun.serve) |
197
- | `?? fallback` | returns fallback | Graceful degradation |
198
-
199
220
  ## Anti-Patterns
200
221
 
201
222
  ```typescript
@@ -217,7 +238,7 @@ await measure('Outer', async (m) => {
217
238
 
218
239
  | Export | Use |
219
240
  |--------|-----|
220
- | `measure(label, fn?)` | Async measurement |
241
+ | `measure(label, fn?, onError?)` | Async measurement (onError handles expected errors) |
221
242
  | `measureSync(label, fn?)` | Sync measurement |
222
243
  | `measure.wrap(label, fn)` | Decorator — wrap once, measure every call |
223
244
  | `measure.batch(label, items, fn, opts?)` | Array + progress |
package/banner.png ADDED
Binary file
package/example.ts CHANGED
@@ -100,18 +100,28 @@ async function main() {
100
100
 
101
101
  // ─── Bun.serve patterns ──────────────────────────────────────────────
102
102
  // measure() returns T | null — on error it returns null instead of throwing.
103
- // In Bun.serve, the fetch handler MUST return a Response. If measure()
104
- // swallows the error and returns null, Bun crashes.
105
- //
106
- // Solution: use measure.assert() which re-throws on error,
107
- // or use nullish coalescing to provide a fallback Response.
103
+ // Use the onError 3rd argument to provide a fallback Response.
108
104
 
109
105
  async function bunServeExample() {
110
106
  console.log('\n─── Bun.serve Patterns ─────────────────────────────');
111
107
 
112
- // ✅ Pattern 1: measure.assertthrows on error, Bun gets a proper crash
108
+ // ✅ Pattern 1: onErrorgraceful 500 fallback with error details
113
109
  const server1 = Bun.serve({
114
- port: 0, // random port
110
+ port: 0,
111
+ fetch: (req) => measure(
112
+ { label: `${req.method} ${new URL(req.url).pathname}` },
113
+ async () => {
114
+ const url = new URL(req.url);
115
+ if (url.pathname === '/fail') throw new Error('Route error');
116
+ return new Response(`ok: ${url.pathname}`);
117
+ },
118
+ (error) => new Response(`Error: ${(error as Error).message}`, { status: 500 })
119
+ ),
120
+ });
121
+
122
+ // ✅ Pattern 2: measure.assert — throws on error (sugar for onError + throw)
123
+ const server2 = Bun.serve({
124
+ port: 0,
115
125
  fetch: (req) => measure.assert('Handle request', async () => {
116
126
  const url = new URL(req.url);
117
127
  if (url.pathname === '/fail') throw new Error('Route error');
@@ -119,34 +129,23 @@ async function bunServeExample() {
119
129
  }),
120
130
  });
121
131
 
122
- // Pattern 2: nullish coalescing graceful 500 fallback
123
- const server2 = Bun.serve({
124
- port: 0,
125
- fetch: async (req) => {
126
- return (await measure('Handle request', async () => {
127
- const url = new URL(req.url);
128
- if (url.pathname === '/fail') throw new Error('Route error');
129
- return new Response(`ok: ${url.pathname}`);
130
- })) ?? new Response('Internal Server Error', { status: 500 });
131
- },
132
- });
132
+ // Test Pattern 1: onError returns fallback Response
133
+ const r1ok = await fetch(`http://localhost:${server1.port}/hello`);
134
+ console.log(` onError pattern (ok): ${r1ok.status} ${await r1ok.text()}`);
133
135
 
134
- // Test both servers
135
- const r1 = await fetch(`http://localhost:${server1.port}/hello`);
136
- console.log(` assert pattern (ok): ${r1.status} ${await r1.text()}`);
136
+ const r1fail = await fetch(`http://localhost:${server1.port}/fail`);
137
+ console.log(` onError pattern (fail): ${r1fail.status} ${await r1fail.text()}`);
138
+
139
+ // Test Pattern 2: assert
140
+ const r2ok = await fetch(`http://localhost:${server2.port}/hello`);
141
+ console.log(` assert pattern (ok): ${r2ok.status} ${await r2ok.text()}`);
137
142
 
138
143
  try {
139
- await fetch(`http://localhost:${server1.port}/fail`);
144
+ await fetch(`http://localhost:${server2.port}/fail`);
140
145
  } catch {
141
146
  console.log(` assert pattern (fail): server rejected (expected)`);
142
147
  }
143
148
 
144
- const r2ok = await fetch(`http://localhost:${server2.port}/hello`);
145
- console.log(` fallback pattern (ok): ${r2ok.status} ${await r2ok.text()}`);
146
-
147
- const r2fail = await fetch(`http://localhost:${server2.port}/fail`);
148
- console.log(` fallback pattern (fail): ${r2fail.status} ${await r2fail.text()}`);
149
-
150
149
  server1.stop();
151
150
  server2.stop();
152
151
  }
package/index.test.ts CHANGED
@@ -427,6 +427,152 @@ describe("measure.assert", () => {
427
427
  });
428
428
  });
429
429
 
430
+ // ─── onError (3rd argument) ──────────────────────────────────────────
431
+
432
+ describe("onError (3rd argument)", () => {
433
+ beforeEach(() => {
434
+ resetCounter();
435
+ configure({ silent: false, logger: null, timestamps: false });
436
+ });
437
+
438
+ test("returns fallback from onError on failure", async () => {
439
+ const out = captureConsole();
440
+ const result = await measure('Fetch user', async () => {
441
+ throw new Error('not found');
442
+ }, () => 'fallback');
443
+ out.restore();
444
+ expect(result).toBe('fallback');
445
+ });
446
+
447
+ test("returns normal result on success (onError ignored)", async () => {
448
+ const out = captureConsole();
449
+ const result = await measure('Fetch user', async () => 42, () => -1);
450
+ out.restore();
451
+ expect(result).toBe(42);
452
+ });
453
+
454
+ test("error object is passed to onError handler", async () => {
455
+ const out = captureConsole();
456
+ const original = new Error('network timeout');
457
+ let captured: unknown = null;
458
+ await measure('Fetch', async () => { throw original; }, (err) => {
459
+ captured = err;
460
+ return null;
461
+ });
462
+ out.restore();
463
+ expect(captured).toBe(original);
464
+ });
465
+
466
+ test("onError rethrow returns null (caught by safety net)", async () => {
467
+ const out = captureConsole();
468
+ const result = await measure('Op', async () => { throw new Error('critical'); }, (e) => { throw e; });
469
+ out.restore();
470
+ expect(result).toBeNull();
471
+ });
472
+
473
+ test("onError can inspect error type and recover", async () => {
474
+ const out = captureConsole();
475
+ const result = await measure('Fetch', async () => {
476
+ throw new TypeError('invalid');
477
+ }, (error) => {
478
+ if (error instanceof TypeError) return 'recovered';
479
+ throw error;
480
+ });
481
+ out.restore();
482
+ expect(result).toBe('recovered');
483
+ });
484
+
485
+ test("still logs error even when onError handles it", async () => {
486
+ const events: any[] = [];
487
+ configure({ logger: (e) => events.push(e) });
488
+ await measure('Op', async () => { throw new Error('x'); }, () => 'fallback');
489
+ configure({ logger: null });
490
+ const errorEvent = events.find(e => e.type === 'error');
491
+ expect(errorEvent).toBeTruthy();
492
+ expect(errorEvent.label).toBe('Op');
493
+ });
494
+
495
+ test("Bun.serve pattern with onError fallback", async () => {
496
+ const out = captureConsole();
497
+ const result = await measure(
498
+ { label: 'Handle request' },
499
+ async () => {
500
+ throw new Error('route error');
501
+ return new Response('ok');
502
+ },
503
+ (error) => new Response(`Error: ${(error as Error).message}`, { status: 500 })
504
+ );
505
+ out.restore();
506
+ expect(result).toBeInstanceOf(Response);
507
+ expect(result!.status).toBe(500);
508
+ expect(await result!.text()).toContain('route error');
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
+ });
525
+ });
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
+ });
430
576
  // ─── measure.wrap ────────────────────────────────────────────────────
431
577
 
432
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 };
@@ -214,16 +220,17 @@ const createNestedResolver = (
214
220
  fullIdChain: string[],
215
221
  childCounterRef: { value: number },
216
222
  depth: number,
217
- resolver: <U>(fn: any, action: any, chain: (string | number)[], depth: number) => Promise<U | null> | (U | null),
223
+ resolver: <U>(fn: any, action: any, chain: (string | number)[], depth: number, onError?: (error: unknown) => any) => Promise<U | null> | (U | null),
218
224
  prefix?: string
219
225
  ) => {
220
226
  return (...args: any[]) => {
221
227
  const label = args[0];
222
228
  const fn = args[1];
229
+ const onError = args[2];
223
230
 
224
231
  if (typeof fn === 'function') {
225
232
  const childParentChain = [...fullIdChain, childCounterRef.value++];
226
- return resolver(fn, label, childParentChain, depth + 1);
233
+ return resolver(fn, label, childParentChain, depth + 1, typeof onError === 'function' ? onError : undefined);
227
234
  } else {
228
235
  emit({
229
236
  type: 'annotation',
@@ -255,12 +262,14 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
255
262
  fnInternal: (measure: MeasureFn) => Promise<U>,
256
263
  actionInternal: string | object,
257
264
  parentIdChain: (string | number)[],
258
- depth: number
265
+ depth: number,
266
+ onError?: (error: unknown) => any
259
267
  ): Promise<U | null> => {
260
268
  const start = performance.now();
261
269
  const childCounterRef = { value: 0 };
262
270
  const label = buildActionLabel(actionInternal);
263
271
  const budget = extractBudget(actionInternal);
272
+ const timeout = extractTimeout(actionInternal);
264
273
 
265
274
  const currentId = toAlpha(parentIdChain.pop() ?? 0);
266
275
  const fullIdChain = [...parentIdChain, currentId];
@@ -277,7 +286,17 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
277
286
  const measureForNextLevel = createNestedResolver(true, fullIdChain, childCounterRef, depth, _measureInternal, prefix);
278
287
 
279
288
  try {
280
- 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
+ }
281
300
  const duration = performance.now() - start;
282
301
  emit({ type: 'success', id: idStr, label, depth, duration, result, budget }, prefix);
283
302
  return result;
@@ -285,6 +304,15 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
285
304
  const duration = performance.now() - start;
286
305
  emit({ type: 'error', id: idStr, label, depth, duration, error, budget }, prefix);
287
306
  _lastError = 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
+ }
288
316
  return null;
289
317
  }
290
318
  };
@@ -334,10 +362,11 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
334
362
 
335
363
  const measureFn = async <T = null>(
336
364
  arg1: string | object,
337
- arg2?: ((measure: MeasureFn) => Promise<T>)
365
+ arg2?: ((measure: MeasureFn) => Promise<T>) | ((measure: MeasureFn) => T),
366
+ arg3?: (error: unknown) => any
338
367
  ): Promise<T | null> => {
339
368
  if (typeof arg2 === 'function') {
340
- return _measureInternal(arg2, arg1, [counter.value++], 0) as Promise<T | null>;
369
+ return _measureInternal(arg2 as any, arg1, [counter.value++], 0, arg3) as Promise<T | null>;
341
370
  } else {
342
371
  const currentId = toAlpha(counter.value++);
343
372
  emit({
package/package.json CHANGED
@@ -2,7 +2,8 @@
2
2
  "name": "measure-fn",
3
3
  "module": "index.ts",
4
4
  "main": "./index.ts",
5
- "version": "3.3.0",
5
+ "types": "./index.ts",
6
+ "version": "3.6.0",
6
7
  "type": "module",
7
8
  "private": false,
8
9
  "description": "Zero-dependency function performance measurement with hierarchical logging",
@@ -29,4 +30,4 @@
29
30
  "test": "bun test",
30
31
  "example": "bun run example.ts"
31
32
  }
32
- }
33
+ }