measure-fn 3.2.0 → 3.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 7flash
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -162,6 +162,31 @@ await api.measure('GET /users', async () => {
162
162
  // → [api:a] ✓ GET /users 45ms → [...]
163
163
  ```
164
164
 
165
+ ### Bun.serve — handling Response
166
+
167
+ `measure()` returns `null` on error instead of throwing. In `Bun.serve`, the fetch handler **must** return a `Response` — returning `null` crashes. Two solutions:
168
+
169
+ ```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
+ });
186
+ ```
187
+
188
+ > **Why not plain `measure()`?** On error it returns `null`, not a `Response`. This is by design — `measure` never throws (except `.assert()`).
189
+
165
190
  ### `configure(opts)` — runtime config
166
191
 
167
192
  ```typescript
@@ -193,9 +218,61 @@ formatDuration(91234); // "1m 31s"
193
218
  resetCounter(); // reset ID counter for tests
194
219
  ```
195
220
 
196
- ## Error Handling
221
+ ## Why `measure` Never Throws
222
+
223
+ This is a deliberate design choice, not a shortcut.
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.
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.
197
228
 
198
- **measure never throws** (except `.assert()`). On error: logs `✗`, returns `null`, prints stack to stderr.
229
+ **Handle recoverable errors inside, let measure catch the rest:**
230
+
231
+ ```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
+ });
242
+ ```
243
+
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:**
247
+
248
+ ```typescript
249
+ // .assert() — re-throws, guarantees non-null
250
+ const user = await measure.assert('Get user', () => fetchUser(1));
251
+
252
+ // ?? — graceful fallback
253
+ const user = (await measure('Get user', () => fetchUser(1))) ?? defaultUser;
254
+ ```
255
+
256
+ **Summary:**
257
+
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
+ ```
199
276
 
200
277
  ## Types
201
278
 
package/SKILL.md CHANGED
@@ -3,111 +3,229 @@ name: measure-fn
3
3
  description: Zero-dependency function performance measurement with hierarchical logging. Use this to structure ALL programs as measured, observable pipelines.
4
4
  ---
5
5
 
6
- # measure-fn — Structured Program Measurement
6
+ # measure-fn — Write Observable Code
7
7
 
8
- **Every program you write should be observable from day one.**
8
+ **Every function you write should be measured.** Don't write blind code — wrap operations in `measure` so every success, failure, and timing is automatically visible. This is the default way to structure programs.
9
9
 
10
- ## Installation
10
+ ## Install
11
11
 
12
12
  ```bash
13
13
  bun add measure-fn
14
14
  ```
15
15
 
16
- ## Output Format
16
+ ## The Rule
17
17
 
18
- ```
19
- [id] ... label — start (async / sync with children)
20
- [id] ✓ label Nms → val — success with auto-printed result
21
- [id]label Nms (err) — error
22
- [id] = label — annotation / progress
18
+ > If a function does I/O, computation, or anything non-trivial — **wrap it in `measure`**.
19
+
20
+ ```typescript
21
+ //BADblind, silent, no timing, no error visibility
22
+ const users = await fetchUsers();
23
+ const config = loadConfig();
24
+
25
+ // ✓ GOOD — observable, timed, errors auto-logged
26
+ const users = await measure('Fetch users', () => fetchUsers());
27
+ const config = measureSync('Load config', () => loadConfig());
23
28
  ```
24
29
 
25
- No indentation, no colors. IDs encode hierarchy. Smart duration: `0.10ms` → `1.2s` → `2m 5s`.
30
+ ## Patterns
26
31
 
27
- ## Core API
32
+ ### 1. Every entry point is a measured pipeline
28
33
 
29
34
  ```typescript
30
- import { measure, measureSync, createMeasure, configure } from 'measure-fn';
35
+ import { measure, measureSync } from 'measure-fn';
36
+
37
+ async function main() {
38
+ const config = measureSync('Load config', () => readConfig());
39
+ const db = await measure('Connect DB', () => connectDatabase(config));
40
+ const users = await measure('Fetch users', () => db.query('SELECT * FROM users'));
41
+ await measure('Send emails', () => sendEmails(users));
42
+ }
43
+ ```
31
44
 
32
- // Async
33
- const users = await measure('Fetch users', () => fetchUsers());
45
+ Output:
46
+ ```
47
+ [a] ✓ Load config 0.12ms → {"env":"prod"}
48
+ [b] ... Connect DB
49
+ [b] ✓ Connect DB 45ms → [DB]
50
+ [c] ... Fetch users
51
+ [c] ✓ Fetch users 23ms → [{"id":1},{"id":2}]
52
+ [d] ... Send emails
53
+ [d] ✓ Send emails 102ms
54
+ ```
34
55
 
35
- // Sync (leaf = single line)
36
- const config = measureSync('Load config', () => loadConfig());
56
+ ### 2. Nested operations use the child measure
37
57
 
38
- // Nested + parallel
58
+ ```typescript
39
59
  await measure('Pipeline', async (m) => {
40
- await Promise.all([
41
- m({ label: 'Fetch', userId: 1 }, () => fetchUser(1)),
42
- m({ label: 'Fetch', userId: 2 }, () => fetchUser(2)),
60
+ const raw = await m('Fetch', () => fetchData());
61
+ const parsed = m('Parse', () => parseData(raw));
62
+ await m('Save', () => saveResult(parsed));
63
+ });
64
+ ```
65
+
66
+ ### 3. Parallel work with `Promise.all`
67
+
68
+ ```typescript
69
+ await measure('Load all', async (m) => {
70
+ const [users, posts, settings] = await Promise.all([
71
+ m('Users', () => fetchUsers()),
72
+ m('Posts', () => fetchPosts()),
73
+ m('Settings', () => fetchSettings()),
43
74
  ]);
75
+ return { users, posts, settings };
44
76
  });
77
+ ```
78
+
79
+ ### 4. Wrap reusable functions once
45
80
 
46
- // Wrap: decorator pattern — wrap once, measure every call
81
+ ```typescript
47
82
  const getUser = measure.wrap('Get user', fetchUser);
48
- await getUser(1);
49
- await getUser(2);
83
+ // Every call is now measured automatically
84
+ await getUser(1); // → [a] ✓ Get user 82ms → {...}
85
+ await getUser(2); // → [b] ✓ Get user 75ms → {...}
86
+ ```
87
+
88
+ ### 5. Process arrays with progress
89
+
90
+ ```typescript
91
+ await measure.batch('Process users', userIds, async (id) => {
92
+ return await processUser(id);
93
+ }, { every: 100 });
94
+ // → [a] ... Process users (500 items)
95
+ // → [a] = 100/500 (1.2s, 83/s)
96
+ // → [a] ✓ Process users (500 items) 5.3s → "500/500 ok"
97
+ ```
98
+
99
+ ### 6. Retry flaky operations
100
+
101
+ ```typescript
102
+ const result = await measure.retry('External API', {
103
+ attempts: 3, delay: 1000, backoff: 2
104
+ }, () => callExternalService());
105
+ ```
106
+
107
+ ### 7. Budget warnings for slow ops
108
+
109
+ ```typescript
110
+ await measure({ label: 'DB query', budget: 100 }, () => heavyQuery());
111
+ // → [a] ✓ DB query 245ms → [...] ⚠ OVER BUDGET (100ms)
112
+ ```
50
113
 
51
- // Batch: process array with progress logging
52
- await measure.batch('Process', items, async (item) => transform(item), { every: 100 });
114
+ ### 8. Assert non-null results
53
115
 
54
- // Retry: automatic retry with backoff
55
- await measure.retry('Flaky', { attempts: 3, delay: 1000, backoff: 2 }, () => flakyApi());
116
+ ```typescript
117
+ // Guaranteed non-null throws if the function returns null/undefined
118
+ const user = await measure.assert('Get user', () => findUser(id));
119
+ ```
56
120
 
57
- // Assert: throws if null (type-narrowing)
58
- const user = await measure.assert('Get user', () => fetchUser(1));
121
+ ### 9. Scoped instances for subsystems
59
122
 
60
- // Budget: warn when operation exceeds time limit
61
- await measure({ label: 'DB query', budget: 100 }, () => query());
123
+ ```typescript
124
+ const api = createMeasure('api');
125
+ const db = createMeasure('db');
62
126
 
63
- // Scoped: separate namespace and counter
64
- const api = createMeasure('api'); // [api:a], [api:b], ...
65
- const db = createMeasure('db'); // → [db:a], [db:b], ...
127
+ await api.measure('GET /users', async () => {
128
+ return await db.measure('SELECT', () => query('SELECT * FROM users'));
129
+ });
130
+ // → [api:a] ... GET /users
131
+ // → [db:a] ✓ SELECT 44ms → [...]
132
+ // → [api:a] ✓ GET /users 45ms → [...]
133
+ ```
134
+
135
+ ### 10. Annotations for checkpoints
136
+
137
+ ```typescript
138
+ await measure('Server ready'); // → [a] = Server ready
139
+ measureSync('Config loaded'); // → [b] = Config loaded
140
+ ```
141
+
142
+ ### 11. Bun.serve — always use `measure.assert` or fallback
143
+
144
+ `measure()` returns `null` on error. In a fetch handler, you **must** return a `Response`:
145
+
146
+ ```typescript
147
+ // ✅ measure.assert — throws on error, pair with Bun.serve error handler
148
+ Bun.serve({
149
+ fetch: (req) => measure.assert('Handle', async () => {
150
+ return new Response('ok');
151
+ }),
152
+ error: () => new Response('Internal Server Error', { status: 500 }),
153
+ });
154
+
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
+ });
66
163
  ```
67
164
 
68
165
  ## Configuration
69
166
 
70
167
  ```typescript
168
+ import { configure } from 'measure-fn';
169
+
71
170
  configure({
72
- silent: true, // suppress output
171
+ silent: true, // suppress output (for benchmarks)
73
172
  timestamps: true, // [HH:MM:SS.mmm] prefix
74
173
  maxResultLength: 200, // result truncation (default: 80)
75
- logger: (event) => ..., // custom event handler
174
+ logger: (event) => { // custom telemetry
175
+ myTracker.send(event);
176
+ },
76
177
  });
77
178
  ```
78
179
 
79
- Env: `MEASURE_SILENT=1`, `MEASURE_TIMESTAMPS=1`
180
+ Env vars: `MEASURE_SILENT=1`, `MEASURE_TIMESTAMPS=1`
80
181
 
81
- ## Utilities
182
+ ## Programmatic Timing
82
183
 
83
184
  ```typescript
84
- import { safeStringify, formatDuration, resetCounter } from 'measure-fn';
185
+ const { result, duration } = await measure.timed('Fetch', () => fetchUsers());
186
+ if (duration > 1000) alert('Slow!');
85
187
  ```
86
188
 
87
- ## API Reference
189
+ ## Error Model — measure Never Throws
88
190
 
89
- | Export | Description |
90
- |--------|-------------|
91
- | `measure(label, fn?)` | Async measurement |
92
- | `measure.timed(label, fn?)` | Returns `{ result, duration }` |
93
- | `measure.retry(label, opts, fn)` | Retry with backoff |
94
- | `measure.assert(label, fn)` | Throws if null |
95
- | `measure.wrap(label, fn)` | Returns measured version of fn |
96
- | `measure.batch(label, items, fn, opts?)` | Array processing with progress |
97
- | `measureSync(label, fn?)` | Sync measurement |
98
- | `measureSync.timed/assert/wrap` | Sync variants |
99
- | `createMeasure(prefix)` | Scoped instance |
100
- | `configure(opts)` | Runtime configuration |
101
- | `resetCounter()` | Reset global ID counter |
102
- | `safeStringify(value)` | Safe JSON with circular ref handling |
103
- | `formatDuration(ms)` | Smart duration formatting |
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 |
104
198
 
105
- ## Testing
199
+ ## Anti-Patterns
106
200
 
107
201
  ```typescript
108
- import { resetCounter, configure } from 'measure-fn';
109
- beforeEach(() => {
110
- resetCounter();
111
- configure({ silent: false, logger: null, timestamps: false });
202
+ // Don't measure trivial synchronous expressions
203
+ const x = measureSync('Add', () => 1 + 1);
204
+
205
+ // Don't nest measure inside measure without using child `m`
206
+ await measure('Outer', async () => {
207
+ await measure('Inner', () => work()); // creates flat siblings, not hierarchy
208
+ });
209
+
210
+ // ✓ Use child measure for hierarchy
211
+ await measure('Outer', async (m) => {
212
+ await m('Inner', () => work()); // proper parent → child
112
213
  });
113
214
  ```
215
+
216
+ ## Quick Reference
217
+
218
+ | Export | Use |
219
+ |--------|-----|
220
+ | `measure(label, fn?)` | Async measurement |
221
+ | `measureSync(label, fn?)` | Sync measurement |
222
+ | `measure.wrap(label, fn)` | Decorator — wrap once, measure every call |
223
+ | `measure.batch(label, items, fn, opts?)` | Array + progress |
224
+ | `measure.retry(label, opts, fn)` | Retry with backoff |
225
+ | `measure.assert(label, fn)` | Throws if null |
226
+ | `measure.timed(label, fn)` | Returns `{ result, duration }` |
227
+ | `createMeasure(prefix)` | Scoped instance |
228
+ | `configure(opts)` | Runtime config |
229
+ | `safeStringify(value)` | Safe JSON (circular refs, truncation) |
230
+ | `formatDuration(ms)` | Smart duration: `0.10ms` → `1.2s` → `2m 5s` |
231
+ | `resetCounter()` | Reset ID counter |
package/bench.ts ADDED
@@ -0,0 +1,54 @@
1
+
2
+ import { measure, measureSync, configure } from "./index.ts";
3
+
4
+ const ITERATIONS = 100_000;
5
+
6
+ function noop() { }
7
+
8
+ async function run() {
9
+ console.log(`Running benchmark with ${ITERATIONS.toLocaleString()} iterations...`);
10
+ configure({ silent: true }); // Disable logging to measure pure overhead
11
+
12
+ // Baseline
13
+ const startBase = performance.now();
14
+ for (let i = 0; i < ITERATIONS; i++) {
15
+ noop();
16
+ }
17
+ const timeBase = performance.now() - startBase;
18
+ const perOpBase = timeBase / ITERATIONS;
19
+ console.log(`Baseline (noop): ${(timeBase).toFixed(2)}ms total, ${perOpBase.toFixed(6)}ms/op`);
20
+
21
+ // measureSync overhead
22
+ const startSync = performance.now();
23
+ for (let i = 0; i < ITERATIONS; i++) {
24
+ measureSync('test', noop);
25
+ }
26
+ const timeSync = performance.now() - startSync;
27
+ const overheadSync = (timeSync - timeBase) / ITERATIONS;
28
+ console.log(`measureSync: ${(timeSync).toFixed(2)}ms total, ${overheadSync.toFixed(6)}ms overhead/op`);
29
+
30
+ // measure (async) overhead
31
+ const startAsync = performance.now();
32
+ for (let i = 0; i < ITERATIONS; i++) {
33
+ await measure('test', async () => { });
34
+ }
35
+ const timeAsync = performance.now() - startAsync;
36
+ const overheadAsync = (timeAsync - timeBase) / ITERATIONS;
37
+ console.log(`measure (async): ${(timeAsync).toFixed(2)}ms total, ${overheadAsync.toFixed(6)}ms overhead/op`);
38
+
39
+ // Nested overhead (depth 3)
40
+ const startNested = performance.now();
41
+ for (let i = 0; i < ITERATIONS / 10; i++) { // reduce iterations
42
+ measureSync('root', (m) => {
43
+ m('child', (m2) => {
44
+ m2('leaf', noop);
45
+ });
46
+ });
47
+ }
48
+ const timeNested = performance.now() - startNested;
49
+ // 3 measurements per iteration
50
+ const perOpNested = timeNested / (ITERATIONS / 10);
51
+ console.log(`Nested (depth 3): ${(timeNested).toFixed(2)}ms total, ${perOpNested.toFixed(6)}ms per tree (3 ops)`);
52
+ }
53
+
54
+ run();
package/bun.lock CHANGED
@@ -1,25 +1,26 @@
1
- {
2
- "lockfileVersion": 1,
3
- "workspaces": {
4
- "": {
5
- "name": "ments-utils",
6
- "devDependencies": {
7
- "@types/bun": "latest",
8
- },
9
- "peerDependencies": {
10
- "typescript": "^5",
11
- },
12
- },
13
- },
14
- "packages": {
15
- "@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="],
16
-
17
- "@types/node": ["@types/node@24.0.4", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ulyqAkrhnuNq9pB76DRBTkcS6YsmDALy6Ua63V8OhrOBgbcYt6IOdzpw5P1+dyRIyMerzLkeYWBeOXPpA9GMAA=="],
18
-
19
- "bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="],
20
-
21
- "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
22
-
23
- "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
24
- }
25
- }
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 0,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "ments-utils",
7
+ "devDependencies": {
8
+ "@types/bun": "latest",
9
+ },
10
+ "peerDependencies": {
11
+ "typescript": "^5",
12
+ },
13
+ },
14
+ },
15
+ "packages": {
16
+ "@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="],
17
+
18
+ "@types/node": ["@types/node@24.0.4", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ulyqAkrhnuNq9pB76DRBTkcS6YsmDALy6Ua63V8OhrOBgbcYt6IOdzpw5P1+dyRIyMerzLkeYWBeOXPpA9GMAA=="],
19
+
20
+ "bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="],
21
+
22
+ "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
23
+
24
+ "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
25
+ }
26
+ }
package/example.ts CHANGED
@@ -98,4 +98,60 @@ async function main() {
98
98
  });
99
99
  }
100
100
 
101
- main().then(() => console.log('\n✅ Done.'));
101
+ // ─── Bun.serve patterns ──────────────────────────────────────────────
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.
108
+
109
+ async function bunServeExample() {
110
+ console.log('\n─── Bun.serve Patterns ─────────────────────────────');
111
+
112
+ // ✅ Pattern 1: measure.assert — throws on error, Bun gets a proper crash
113
+ const server1 = Bun.serve({
114
+ port: 0, // random port
115
+ fetch: (req) => measure.assert('Handle request', async () => {
116
+ const url = new URL(req.url);
117
+ if (url.pathname === '/fail') throw new Error('Route error');
118
+ return new Response(`ok: ${url.pathname}`);
119
+ }),
120
+ });
121
+
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
+ });
133
+
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()}`);
137
+
138
+ try {
139
+ await fetch(`http://localhost:${server1.port}/fail`);
140
+ } catch {
141
+ console.log(` assert pattern (fail): server rejected (expected)`);
142
+ }
143
+
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
+ server1.stop();
151
+ server2.stop();
152
+ }
153
+
154
+ main()
155
+ .then(() => console.log('\n✅ Done.'))
156
+ .then(() => bunServeExample())
157
+ .then(() => console.log('\n✅ Bun.serve example done.'));
package/index.test.ts CHANGED
@@ -394,13 +394,15 @@ describe("measure.assert", () => {
394
394
  expect(r).toBe(42);
395
395
  });
396
396
 
397
- test("throws on error", async () => {
397
+ test("throws on error with original cause", async () => {
398
398
  const out = captureConsole();
399
+ const original = new Error("connection refused");
399
400
  try {
400
- await measure.assert("fail", async () => { throw new Error("x"); });
401
+ await measure.assert("fail", async () => { throw original; });
401
402
  expect(true).toBe(false);
402
403
  } catch (e: any) {
403
404
  expect(e.message).toContain("fail");
405
+ expect(e.cause).toBe(original);
404
406
  }
405
407
  out.restore();
406
408
  });
@@ -411,13 +413,15 @@ describe("measure.assert", () => {
411
413
  out.restore();
412
414
  });
413
415
 
414
- test("sync assert throws", () => {
416
+ test("sync assert throws with original cause", () => {
415
417
  const out = captureConsole();
418
+ const original = new Error("parse error");
416
419
  try {
417
- measureSync.assert("fail", () => { throw new Error("x"); });
420
+ measureSync.assert("fail", () => { throw original; });
418
421
  expect(true).toBe(false);
419
422
  } catch (e: any) {
420
423
  expect(e.message).toContain("fail");
424
+ expect(e.cause).toBe(original);
421
425
  }
422
426
  out.restore();
423
427
  });
@@ -585,3 +589,60 @@ describe("ID generation", () => {
585
589
  expect(out.logs[52]).toStartWith("[aa]");
586
590
  });
587
591
  });
592
+
593
+ // ─── Bun.serve patterns ─────────────────────────────────────────────
594
+
595
+ describe("Bun.serve pattern", () => {
596
+ beforeEach(() => {
597
+ resetCounter();
598
+ configure({ silent: false, logger: null, timestamps: false });
599
+ });
600
+
601
+ test("measure returns null on error — breaks fetch handler", async () => {
602
+ const out = captureConsole();
603
+ const result = await measure("handle", async () => {
604
+ throw new Error("route error");
605
+ return new Response("ok");
606
+ });
607
+ out.restore();
608
+ expect(result).toBeNull(); // Bun.serve would crash with null
609
+ });
610
+
611
+ test("measure.assert returns Response on success", async () => {
612
+ const out = captureConsole();
613
+ const result = await measure.assert("handle", async () => {
614
+ return new Response("ok");
615
+ });
616
+ out.restore();
617
+ expect(result).toBeInstanceOf(Response);
618
+ expect(await result.text()).toBe("ok");
619
+ });
620
+
621
+ test("measure.assert throws on error with cause — Bun.serve can catch it", async () => {
622
+ const out = captureConsole();
623
+ const original = new Error("route error");
624
+ try {
625
+ await measure.assert("handle", async () => {
626
+ throw original;
627
+ return new Response("ok");
628
+ });
629
+ expect(true).toBe(false); // should not reach
630
+ } catch (e: any) {
631
+ expect(e.message).toContain("handle");
632
+ expect(e.cause).toBe(original);
633
+ }
634
+ out.restore();
635
+ });
636
+
637
+ test("nullish coalescing fallback pattern", async () => {
638
+ const out = captureConsole();
639
+ const result = (await measure("handle", async () => {
640
+ throw new Error("route error");
641
+ return new Response("ok");
642
+ })) ?? new Response("Internal Server Error", { status: 500 });
643
+ out.restore();
644
+ expect(result).toBeInstanceOf(Response);
645
+ expect(result.status).toBe(500);
646
+ expect(await result.text()).toBe("Internal Server Error");
647
+ });
648
+ });
package/index.ts CHANGED
@@ -249,6 +249,7 @@ export const resetCounter = () => {
249
249
 
250
250
  const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
251
251
  const counter = counterRef ?? { get value() { return globalRootCounter; }, set value(v) { globalRootCounter = v; } };
252
+ let _lastError: unknown = null;
252
253
 
253
254
  const _measureInternal = async <U>(
254
255
  fnInternal: (measure: MeasureFn) => Promise<U>,
@@ -283,6 +284,7 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
283
284
  } catch (error) {
284
285
  const duration = performance.now() - start;
285
286
  emit({ type: 'error', id: idStr, label, depth, duration, error, budget }, prefix);
287
+ _lastError = error;
286
288
  return null;
287
289
  }
288
290
  };
@@ -323,6 +325,7 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
323
325
  } catch (error) {
324
326
  const duration = performance.now() - start;
325
327
  emit({ type: 'error', id: idStr, label, depth, duration, error, budget }, prefix);
328
+ _lastError = error;
326
329
  return null;
327
330
  }
328
331
  };
@@ -405,7 +408,9 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
405
408
  ): Promise<T> => {
406
409
  const result = await measureFn(arg1, arg2 as any);
407
410
  if (result === null) {
408
- throw new Error(`measure.assert: "${buildActionLabel(arg1)}" returned null`);
411
+ const cause = _lastError;
412
+ _lastError = null;
413
+ throw new Error(`measure.assert: "${buildActionLabel(arg1)}" failed`, { cause });
409
414
  }
410
415
  return result;
411
416
  };
@@ -507,7 +512,9 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
507
512
  ): T => {
508
513
  const result = measureSyncFn(arg1, arg2 as any);
509
514
  if (result === null) {
510
- throw new Error(`measureSync.assert: "${buildActionLabel(arg1)}" returned null`);
515
+ const cause = _lastError;
516
+ _lastError = null;
517
+ throw new Error(`measureSync.assert: "${buildActionLabel(arg1)}" failed`, { cause });
511
518
  }
512
519
  return result;
513
520
  };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "measure-fn",
3
3
  "module": "index.ts",
4
4
  "main": "./index.ts",
5
- "version": "3.2.0",
5
+ "version": "3.3.0",
6
6
  "type": "module",
7
7
  "private": false,
8
8
  "description": "Zero-dependency function performance measurement with hierarchical logging",