measure-fn 3.3.0 → 3.5.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
@@ -218,61 +218,67 @@ formatDuration(91234); // "1m 31s"
218
218
  resetCounter(); // reset ID counter for tests
219
219
  ```
220
220
 
221
- ## Why `measure` Never Throws
221
+ ## Error Handling
222
222
 
223
- This is a deliberate design choice, not a shortcut.
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
224
 
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.
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`:
226
226
 
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.
227
+ ```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
+ );
235
+
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
+ );
228
244
 
229
- **Handle recoverable errors inside, let measure catch the rest:**
245
+ // Rethrow all: transparent observability (same as .assert())
246
+ const user = await measure('Fetch user', () => fetchUser(1),
247
+ (error) => { throw error }
248
+ );
249
+ ```
250
+
251
+ **Bun.serve — never return null:**
230
252
 
231
253
  ```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
- }
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
+ ),
241
260
  });
242
261
  ```
243
262
 
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:**
263
+ **`.assert()` is sugar for the rethrow pattern:**
247
264
 
248
265
  ```typescript
249
- // .assert() re-throws, guarantees non-null
250
- const user = await measure.assert('Get user', () => fetchUser(1));
266
+ // These are equivalent:
267
+ await measure.assert('Op', () => work());
268
+ await measure('Op', () => work(), (e) => { throw e });
251
269
 
252
- // ?? graceful fallback
253
- const user = (await measure('Get user', () => fetchUser(1))) ?? defaultUser;
270
+ // .assert() wraps the error with .cause for inspection:
271
+ // e.message 'measure.assert: "Op" failed'
272
+ // e.cause → original error
254
273
  ```
255
274
 
256
275
  **Summary:**
257
276
 
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 |
264
-
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:
266
-
267
- ```typescript
268
- // ✗ Ambiguous: did handler or measure catch the error?
269
- const response = await measure('Handle', () => handler(req)); // null = ???
270
-
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)
275
- ```
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 |
276
282
 
277
283
  ## Types
278
284
 
package/SKILL.md CHANGED
@@ -139,29 +139,52 @@ await measure('Server ready'); // → [a] = Server ready
139
139
  measureSync('Config loaded'); // → [b] = Config loaded
140
140
  ```
141
141
 
142
- ### 11. Bun.servealways use `measure.assert` or fallback
142
+ ### 11. Error handling — `onError` 3rd argument
143
143
 
144
- `measure()` returns `null` on error. In a fetch handler, you **must** return a `Response`:
144
+ `measure` never throws. Pass an `onError` handler as 3rd argument to handle errors:
145
145
 
146
146
  ```typescript
147
- // measure.assert — throws on error, pair with Bun.serve error handler
147
+ // Default: null on error
148
+ const user = await measure('Fetch user', () => fetchUser(1));
149
+
150
+ // Recovery: fallback on error
151
+ const user = await measure('Fetch user', () => fetchUser(1),
152
+ (error) => defaultUser
153
+ );
154
+
155
+ // Error inspection: handle known errors, rethrow unknown
156
+ const user = await measure('Fetch user', () => fetchUser(1),
157
+ (error) => {
158
+ if (error instanceof NetworkError) return cachedUser;
159
+ throw error;
160
+ }
161
+ );
162
+
163
+ // Bun.serve: always return a Response
148
164
  Bun.serve({
149
- fetch: (req) => measure.assert('Handle', async () => {
150
- return new Response('ok');
151
- }),
152
- error: () => new Response('Internal Server Error', { status: 500 }),
165
+ fetch: (req) => measure(
166
+ { label: `${req.method} ${req.url}` },
167
+ () => handleRequest(req),
168
+ (error) => new Response('Internal Server Error', { status: 500 })
169
+ ),
153
170
  });
171
+ ```
154
172
 
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
- });
173
+ `.assert()` is sugar for `(e) => { throw e }` with `.cause`:
174
+
175
+ ```typescript
176
+ await measure.assert('Op', () => work());
177
+ // equivalent to: measure('Op', () => work(), (e) => { throw e })
163
178
  ```
164
179
 
180
+ ## Error Model
181
+
182
+ | Pattern | On error | Use when |
183
+ |---------|----------|----------|
184
+ | `measure(label, fn)` | logs `✗`, returns `null` | Default — pipeline resilience |
185
+ | `measure(label, fn, onError)` | logs `✗`, calls `onError(error)` | Recovery, fallbacks, error inspection |
186
+ | `measure.assert(label, fn)` | logs `✗`, throws with `.cause` | Must have non-null |
187
+
165
188
  ## Configuration
166
189
 
167
190
  ```typescript
@@ -186,16 +209,6 @@ const { result, duration } = await measure.timed('Fetch', () => fetchUsers());
186
209
  if (duration > 1000) alert('Slow!');
187
210
  ```
188
211
 
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
212
  ## Anti-Patterns
200
213
 
201
214
  ```typescript
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,114 @@ 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 can rethrow (same as assert)", async () => {
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
+ }
475
+ out.restore();
476
+ });
477
+
478
+ test("onError can inspect error type and recover", async () => {
479
+ const out = captureConsole();
480
+ const result = await measure('Fetch', async () => {
481
+ throw new TypeError('invalid');
482
+ }, (error) => {
483
+ if (error instanceof TypeError) return 'recovered';
484
+ throw error;
485
+ });
486
+ out.restore();
487
+ expect(result).toBe('recovered');
488
+ });
489
+
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
+ test("still logs error even when onError handles it", async () => {
512
+ const events: any[] = [];
513
+ configure({ logger: (e) => events.push(e) });
514
+ await measure('Op', async () => { throw new Error('x'); }, () => 'fallback');
515
+ configure({ logger: null });
516
+ const errorEvent = events.find(e => e.type === 'error');
517
+ expect(errorEvent).toBeTruthy();
518
+ expect(errorEvent.label).toBe('Op');
519
+ });
520
+
521
+ test("Bun.serve pattern with onError fallback", async () => {
522
+ const out = captureConsole();
523
+ const result = await measure(
524
+ { label: 'Handle request' },
525
+ async () => {
526
+ throw new Error('route error');
527
+ return new Response('ok');
528
+ },
529
+ (error) => new Response(`Error: ${(error as Error).message}`, { status: 500 })
530
+ );
531
+ out.restore();
532
+ expect(result).toBeInstanceOf(Response);
533
+ expect(result!.status).toBe(500);
534
+ expect(await result!.text()).toContain('route error');
535
+ });
536
+ });
537
+
430
538
  // ─── measure.wrap ────────────────────────────────────────────────────
431
539
 
432
540
  describe("measure.wrap", () => {
package/index.ts CHANGED
@@ -214,16 +214,17 @@ const createNestedResolver = (
214
214
  fullIdChain: string[],
215
215
  childCounterRef: { value: number },
216
216
  depth: number,
217
- resolver: <U>(fn: any, action: any, chain: (string | number)[], depth: number) => Promise<U | null> | (U | null),
217
+ resolver: <U>(fn: any, action: any, chain: (string | number)[], depth: number, onError?: (error: unknown) => any) => Promise<U | null> | (U | null),
218
218
  prefix?: string
219
219
  ) => {
220
220
  return (...args: any[]) => {
221
221
  const label = args[0];
222
222
  const fn = args[1];
223
+ const onError = args[2];
223
224
 
224
225
  if (typeof fn === 'function') {
225
226
  const childParentChain = [...fullIdChain, childCounterRef.value++];
226
- return resolver(fn, label, childParentChain, depth + 1);
227
+ return resolver(fn, label, childParentChain, depth + 1, typeof onError === 'function' ? onError : undefined);
227
228
  } else {
228
229
  emit({
229
230
  type: 'annotation',
@@ -255,7 +256,8 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
255
256
  fnInternal: (measure: MeasureFn) => Promise<U>,
256
257
  actionInternal: string | object,
257
258
  parentIdChain: (string | number)[],
258
- depth: number
259
+ depth: number,
260
+ onError?: (error: unknown) => any
259
261
  ): Promise<U | null> => {
260
262
  const start = performance.now();
261
263
  const childCounterRef = { value: 0 };
@@ -285,6 +287,7 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
285
287
  const duration = performance.now() - start;
286
288
  emit({ type: 'error', id: idStr, label, depth, duration, error, budget }, prefix);
287
289
  _lastError = error;
290
+ if (onError) return onError(error);
288
291
  return null;
289
292
  }
290
293
  };
@@ -293,7 +296,8 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
293
296
  fnInternal: (measure: MeasureSyncFn) => U,
294
297
  actionInternal: string | object,
295
298
  parentIdChain: (string | number)[],
296
- depth: number
299
+ depth: number,
300
+ onError?: (error: unknown) => any
297
301
  ): U | null => {
298
302
  const start = performance.now();
299
303
  const childCounterRef = { value: 0 };
@@ -326,6 +330,7 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
326
330
  const duration = performance.now() - start;
327
331
  emit({ type: 'error', id: idStr, label, depth, duration, error, budget }, prefix);
328
332
  _lastError = error;
333
+ if (onError) return onError(error);
329
334
  return null;
330
335
  }
331
336
  };
@@ -334,10 +339,11 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
334
339
 
335
340
  const measureFn = async <T = null>(
336
341
  arg1: string | object,
337
- arg2?: ((measure: MeasureFn) => Promise<T>)
342
+ arg2?: ((measure: MeasureFn) => Promise<T>) | ((measure: MeasureFn) => T),
343
+ arg3?: (error: unknown) => any
338
344
  ): Promise<T | null> => {
339
345
  if (typeof arg2 === 'function') {
340
- return _measureInternal(arg2, arg1, [counter.value++], 0) as Promise<T | null>;
346
+ return _measureInternal(arg2 as any, arg1, [counter.value++], 0, arg3) as Promise<T | null>;
341
347
  } else {
342
348
  const currentId = toAlpha(counter.value++);
343
349
  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.5.0",
6
7
  "type": "module",
7
8
  "private": false,
8
9
  "description": "Zero-dependency function performance measurement with hierarchical logging",