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 CHANGED
@@ -1,302 +1,262 @@
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.
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
- [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
- ```
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
- No setup. No dashboards. Just wrap your functions.
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
- ## Install
17
+ **Before:**
28
18
 
29
- ```sh
30
- bun add measure-fn
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
- ## Quick Start
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, measureSync } from 'measure-fn';
35
+ import { measure } from 'measure-fn';
37
36
 
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}]
37
+ const users = await measure('Fetch users', () => fetchUsers());
38
+ // [a] ··········· 86ms [{"id":1},{"id":2}]
48
39
  ```
49
40
 
50
- ## Output Format
41
+ ## Installation
51
42
 
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` |
43
+ ```sh
44
+ npm install measure-fn
45
+ # or bun add / pnpm add / yarn add
46
+ ```
58
47
 
59
- **No indentation, no colors.** IDs encode hierarchy. Return values auto-print. Circular refs → `[Circular]`, long values truncated.
48
+ ## Defaults
60
49
 
61
- **Smart duration**: `0.10ms` `1.2s` → `2m 5s`
50
+ Every `measure` call automatically:
62
51
 
63
- ## API
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
- ### `measure(label, fn?)` async
56
+ ## 🌳 Nested Calls (Tracing)
66
57
 
67
- ```typescript
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
- // Nested + parallel
60
+ ```typescript
72
61
  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
- ]);
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
- // Annotation
80
- await measure('checkpoint');
68
+ ```
69
+ [a] ... Pipeline
70
+ [a-a] ·········· 82ms → {"id":1}
71
+ [a-b] ··········· 45ms → [...]
72
+ [a] ········ 128ms
81
73
  ```
82
74
 
83
- ### `measureSync(label, fn?)` synchronous
75
+ Parallel execution works cleanly too:
84
76
 
85
77
  ```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));
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
- ### `measure.wrap(label, fn)` — decorator
86
+ ## 🛡️ Error Handling
97
87
 
98
- Wrap a function once, every call is measured:
88
+ By default, errors return `null` so your pipelines can continue safely:
99
89
 
100
90
  ```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 → {...}
91
+ const user = await measure('Fetch user', () => fetchUser(1));
92
+ // If it throwslogs ✗, user = null
104
93
  ```
105
94
 
106
- ### `measure.batch(label, items, fn, opts?)` array processing with progress
95
+ **Custom Fallbacks:** Pass `onError` as the 3rd argument:
107
96
 
108
97
  ```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"
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
- ### `measure.retry(label, opts, fn)` retry with backoff
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
- ### `measure.assert(label, fn)` throw if null
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
- // guaranteed non-null, or throws
110
+ // If it throws → logs ✗, re-throws with .cause = original error
140
111
  ```
141
112
 
142
- ### Budget warn on slow operations
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
- ```typescript
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
- ### `createMeasure(prefix)` scoped instances
121
+ The first argument can be a label string, or an options object:
152
122
 
153
- ```typescript
154
- const api = createMeasure('api');
155
- const db = createMeasure('db');
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
- 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 → [...]
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
- ### Bun.serve handling Response
137
+ Works with `onError` fallback too.
166
138
 
167
- `measure()` returns `null` on error instead of throwing. In `Bun.serve`, the fetch handler **must** return a `Response` — returning `null` crashes. Two solutions:
139
+ **Budget** (warn):
168
140
 
169
141
  ```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
- },
185
- });
142
+ await measure({ label: 'DB query', budget: 100 }, () => db.query('...'));
143
+ // → [a] ········ 245ms → [...] ⚠ OVER BUDGET (100ms)
186
144
  ```
187
145
 
188
- > **Why not plain `measure()`?** On error it returns `null`, not a `Response`. This is by design — `measure` never throws (except `.assert()`).
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
- ### `configure(opts)` — runtime config
152
+ **Metadata context:**
191
153
 
192
154
  ```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
- }
200
- });
155
+ await measure({ label: 'Fetch user', userId: 1 }, () => fetchUser(1));
156
+ // [a] ... Fetch user (userId=1)
201
157
  ```
202
158
 
203
- Env: `MEASURE_SILENT=1`, `MEASURE_TIMESTAMPS=1`
159
+ ## 🧰 Extensions
204
160
 
205
- ### `measure.timed(label, fn?)` — programmatic timing
161
+ ### `measure.wrap(label, fn)`
162
+
163
+ Wrap a function once, measure every time it's called:
206
164
 
207
165
  ```typescript
208
- const { result, duration } = await measure.timed('Fetch', () => fetchUsers());
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
- ### Utilities
171
+ ### `measure.batch(label, items, fn, opts?)`
212
172
 
213
- ```typescript
214
- import { safeStringify, formatDuration, resetCounter } from 'measure-fn';
173
+ Process arrays with built-in progress logs:
215
174
 
216
- safeStringify({ circular: self }); // handles circular refs, truncates
217
- formatDuration(91234); // "1m 31s"
218
- resetCounter(); // reset ID counter for tests
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
- ## Error Handling
184
+ ### `measure.retry(label, opts, fn)`
222
185
 
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`:
186
+ Automatic retries with delay and backoff:
226
187
 
227
188
  ```typescript
228
- // Default: returns null on error
229
- const user = await measure('Fetch user', () => fetchUser(1));
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
- // With recovery: returns fallback on error
232
- const user = await measure('Fetch user', () => fetchUser(1),
233
- (error) => defaultUser
234
- );
196
+ ### `measure.timed(label, fn?)`
235
197
 
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
- );
198
+ Get duration programmatically alongside the result:
244
199
 
245
- // Rethrow all: transparent observability (same as .assert())
246
- const user = await measure('Fetch user', () => fetchUser(1),
247
- (error) => { throw error }
248
- );
200
+ ```typescript
201
+ const { result, duration } = await measure.timed('Fetch', () => fetchUsers());
249
202
  ```
250
203
 
251
- **Bun.serve — never return null:**
204
+ ### `createMeasure(prefix)`
205
+
206
+ Scoped instances with custom prefixes:
252
207
 
253
208
  ```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
- ),
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
- **`.assert()` is sugar for the rethrow pattern:**
220
+ ### Annotations & Sync
264
221
 
265
222
  ```typescript
266
- // These are equivalent:
267
- await measure.assert('Op', () => work());
268
- await measure('Op', () => work(), (e) => { throw e });
223
+ import { measureSync } from 'measure-fn';
269
224
 
270
- // .assert() wraps the error with .cause for inspection:
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
- | 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 |
227
+ await measure('Server ready');
228
+ // → [a] = Server ready
229
+ ```
282
230
 
283
- ## Types
231
+ ## ⚙️ Configuration
284
232
 
285
233
  ```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 };
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
- ## Zero Dependencies
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
- Works in Bun, Node, Deno. Uses only `performance.now()` and `console`.
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 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
@@ -31,12 +31,12 @@ describe("measure (async)", () => {
31
31
  expect(result).toBe(42);
32
32
  });
33
33
 
34
- test("logs start ... and success with result", async () => {
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\] fetch .* → "ok"/);
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\] compute .* → 100/);
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\] child .* → 10/);
135
- expect(out.logs[2]).toMatch(/\[a\] parent .* → 20/);
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("")).length).toBe(1);
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 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", () => {
@@ -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("✓ triple");
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} ${event.label} ${formatDuration(event.duration!)}${arrow}${budgetWarn}`);
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} ✗ ${event.label} ${formatDuration(event.duration!)} (${errorMsg})${budgetWarn}`);
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
- const result = await fnInternal(measureForNextLevel as MeasureFn);
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) return onError(error);
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
@@ -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.7.0",
7
7
  "type": "module",
8
8
  "private": false,
9
9
  "description": "Zero-dependency function performance measurement with hierarchical logging",