measure-fn 3.6.0 → 3.8.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.
Files changed (4) hide show
  1. package/README.md +135 -101
  2. package/index.test.ts +59 -14
  3. package/index.ts +49 -21
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -2,154 +2,176 @@
2
2
  <img src="banner.png" alt="measure-fn" width="100%" />
3
3
  </p>
4
4
 
5
- Wrap functions with automatic try-catch, timing, timeouts, and structured logging.
5
+ <p align="center">
6
+ <b>Replace try-catch + timing boilerplate in TypeScript with a single line of code.</b>
7
+ </p>
6
8
 
7
- ```sh
8
- bun add measure-fn
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>
14
+
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:
16
+
17
+ **Before:**
18
+
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
+ }
9
30
  ```
10
31
 
32
+ **After:** measure-fn does the exact same thing in one line. Completely type-safe (infers `T | null`) and never crashes.
33
+
11
34
  ```typescript
12
- import { measure, measureSync } from 'measure-fn';
35
+ import { measure } from 'measure-fn';
13
36
 
14
37
  const users = await measure('Fetch users', () => fetchUsers());
15
- const config = measureSync('Parse config', () => JSON.parse(raw));
38
+ // [a] ··········· 86ms [{"id":1},{"id":2}]
16
39
  ```
17
40
 
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
- ```
41
+ ## Installation
23
42
 
24
- ## Defaults
43
+ ```sh
44
+ npm install measure-fn
45
+ # or bun add / pnpm add / yarn add
46
+ ```
25
47
 
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
48
+ ## Defaults
30
49
 
31
- ## Error Handling
50
+ Every `measure` call automatically:
32
51
 
33
- By default, errors return `null`:
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
34
55
 
35
- ```typescript
36
- const user = await measure('Fetch user', () => fetchUser(1));
37
- // throws → logs ✗, user = null
38
- ```
56
+ ## 🌳 Nested Calls (Tracing)
39
57
 
40
- Pass `onError` as 3rd argument to provide a fallback:
58
+ Pass a child `m` function to get hierarchical APM-like tracing for free:
41
59
 
42
60
  ```typescript
43
- const user = await measure('Fetch user', () => fetchUser(1),
44
- (error) => defaultUser
45
- );
46
- // throws → logs ✗, user = defaultUser
61
+ await measure('Pipeline', async (m) => {
62
+ const user = await m('Fetch user', () => fetchUser(1));
63
+ const posts = await m('Fetch posts', () => fetchPosts(user.id));
64
+ return posts;
65
+ });
47
66
  ```
48
67
 
49
- If `onError` itself throws, that's also caught — returns `null`. Measure never crashes.
68
+ ```
69
+ [a] ... Pipeline
70
+ [a-a] ·········· 82ms → {"id":1}
71
+ [a-b] ··········· 45ms → [...]
72
+ [a] ········ 128ms
73
+ ```
50
74
 
51
- Use `.assert()` when you need a guaranteed non-null result:
75
+ Parallel execution works cleanly too:
52
76
 
53
77
  ```typescript
54
- const user = await measure.assert('Get user', () => fetchUser(1));
55
- // throws logs ✗, re-throws with .cause = original error
78
+ await measure('Load all', async (m) => {
79
+ const [users, posts] = await Promise.all([
80
+ m('Users', () => fetchUsers()),
81
+ m('Posts', () => fetchPosts()),
82
+ ]);
83
+ });
56
84
  ```
57
85
 
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` |
86
+ ## 🛡️ Error Handling
63
87
 
64
- ## Timeout
65
-
66
- Set `timeout` in the label object to abort after N milliseconds:
88
+ By default, errors return `null` so your pipelines can continue safely:
67
89
 
68
90
  ```typescript
69
- const data = await measure({ label: 'Slow API', timeout: 5000 }, () => fetchSlowApi());
70
- // > 5s Slow API 5.0s (Timeout (5.0s)), returns null
91
+ const user = await measure('Fetch user', () => fetchUser(1));
92
+ // If it throws logs ✗, user = null
71
93
  ```
72
94
 
73
- Works with `onError`:
95
+ **Custom Fallbacks:** Pass `onError` as the 3rd argument:
74
96
 
75
97
  ```typescript
76
- const data = await measure({ label: 'Slow API', timeout: 5000 }, () => fetchSlowApi(),
77
- (error) => cachedData
98
+ const user = await measure('Fetch user', () => fetchUser(1),
99
+ (error) => defaultUser
78
100
  );
101
+ // If it throws → logs ✗, user = defaultUser
79
102
  ```
80
103
 
81
- ## Budget
104
+ If the `onError` fallback itself throws, that's also safely caught and returns `null`. measure never crashes.
82
105
 
83
- Set `budget` to log a warning when a call exceeds the expected time (doesn't abort):
106
+ **Fail-Fast (`.assert`):** Use `.assert()` when you need a guaranteed non-null result:
84
107
 
85
108
  ```typescript
86
- await measure({ label: 'DB query', budget: 100 }, () => db.query('...'));
87
- // [a] DB query 245ms [...] OVER BUDGET (100ms)
109
+ const user = await measure.assert('Get user', () => fetchUser(1));
110
+ // If it throws logs ✗, re-throws with .cause = original error
88
111
  ```
89
112
 
90
- Combine both budget warns, timeout enforces:
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` |
91
118
 
92
- ```typescript
93
- await measure({ label: 'Query', budget: 100, timeout: 5000 }, () => query());
94
- ```
119
+ ## 🚦 Timeouts & Budgets
95
120
 
96
- ## Nested Calls
121
+ The first argument can be a label string, or an options object:
97
122
 
98
- Pass a child `m` to create hierarchy:
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
+ | `maxResultLength` | `number` | Override result truncation (0 = unlimited, inherits to children) |
129
+ | any other | `any` | Logged inline as context metadata |
130
+
131
+ **Timeout** (enforce):
99
132
 
100
133
  ```typescript
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;
105
- });
134
+ const data = await measure({ label: 'Slow API', timeout: 5000 }, () => fetchSlowApi());
135
+ // > 5s Slow API 5.0s (Timeout (5.0s)), returns null
106
136
  ```
107
137
 
108
- ```
109
- [a] ... Pipeline
110
- [a-a] ✓ Fetch user 82ms → {"id":1}
111
- [a-b] ✓ Fetch posts 45ms → [...]
112
- [a] ✓ Pipeline 128ms
113
- ```
138
+ Works with `onError` fallback too.
114
139
 
115
- Parallel:
140
+ **Budget** (warn):
116
141
 
117
142
  ```typescript
118
- await measure('Load all', async (m) => {
119
- const [users, posts] = await Promise.all([
120
- m('Users', () => fetchUsers()),
121
- m('Posts', () => fetchPosts()),
122
- ]);
123
- });
143
+ await measure({ label: 'DB query', budget: 100 }, () => db.query('...'));
144
+ // [a] ········ 245ms → [...] OVER BUDGET (100ms)
124
145
  ```
125
146
 
126
- ## Label Object
147
+ Combine both — budget warns early, timeout enforces a hard stop:
127
148
 
128
- The first argument can be a string or an object. Object fields:
149
+ ```typescript
150
+ await measure({ label: 'Query', budget: 100, timeout: 5000 }, () => query());
151
+ ```
129
152
 
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 |
153
+ **Metadata context:**
136
154
 
137
155
  ```typescript
138
- await measure({ label: 'Fetch user', userId: 1, timeout: 3000 }, () => fetchUser(1));
156
+ await measure({ label: 'Fetch user', userId: 1 }, () => fetchUser(1));
139
157
  // → [a] ... Fetch user (userId=1)
140
158
  ```
141
159
 
142
- ## Extensions
160
+ ## 🧰 Extensions
161
+
162
+ ### `measure.wrap(label, fn)`
143
163
 
144
- ### `measure.wrap(label, fn)` — wrap once, measure every call
164
+ Wrap a function once, measure every time it's called:
145
165
 
146
166
  ```typescript
147
167
  const getUser = measure.wrap('Get user', fetchUser);
148
- await getUser(1); // → [a] Get user 82ms
149
- await getUser(2); // → [b] Get user 75ms
168
+ await getUser(1); // → [a] ········ 82ms
169
+ await getUser(2); // → [b] ········ 75ms
150
170
  ```
151
171
 
152
- ### `measure.batch(label, items, fn, opts?)` — array with progress
172
+ ### `measure.batch(label, items, fn, opts?)`
173
+
174
+ Process arrays with built-in progress logs:
153
175
 
154
176
  ```typescript
155
177
  const results = await measure.batch('Process', userIds, async (id) => {
@@ -157,26 +179,32 @@ const results = await measure.batch('Process', userIds, async (id) => {
157
179
  }, { every: 100 });
158
180
  // → [a] ... Process (500 items)
159
181
  // → [a] = 100/500 (1.2s, 83/s)
160
- // → [a] Process (500 items) 5.3s → "500/500 ok"
182
+ // → [a] ················· 5.3s → "500/500 ok"
161
183
  ```
162
184
 
163
- ### `measure.retry(label, opts, fn)` — retry with backoff
185
+ ### `measure.retry(label, opts, fn)`
186
+
187
+ Automatic retries with delay and backoff:
164
188
 
165
189
  ```typescript
166
190
  const result = await measure.retry('Flaky API', {
167
191
  attempts: 3, delay: 1000, backoff: 2
168
192
  }, () => fetchFlakyApi());
169
193
  // → [a] ✗ Flaky API [1/3] 102ms (timeout)
170
- // → [b] Flaky API [2/3] 89ms → {"status":"ok"}
194
+ // → [b] ················· 89ms → {"status":"ok"}
171
195
  ```
172
196
 
173
- ### `measure.timed(label, fn?)` — get duration programmatically
197
+ ### `measure.timed(label, fn?)`
198
+
199
+ Get duration programmatically alongside the result:
174
200
 
175
201
  ```typescript
176
202
  const { result, duration } = await measure.timed('Fetch', () => fetchUsers());
177
203
  ```
178
204
 
179
- ### `createMeasure(prefix)` — scoped instance
205
+ ### `createMeasure(prefix)`
206
+
207
+ Scoped instances with custom prefixes:
180
208
 
181
209
  ```typescript
182
210
  const api = createMeasure('api');
@@ -186,18 +214,22 @@ await api.measure('GET /users', async () => {
186
214
  return await db.measure('SELECT', () => query('...'));
187
215
  });
188
216
  // → [api:a] ... GET /users
189
- // → [db:a] SELECT 44ms
190
- // → [api:a] GET /users 45ms
217
+ // → [db:a] ······ 44ms
218
+ // → [api:a] ·········· 45ms
191
219
  ```
192
220
 
193
- ### Annotations
221
+ ### Annotations & Sync
194
222
 
195
223
  ```typescript
224
+ import { measureSync } from 'measure-fn';
225
+
226
+ const config = measureSync('Parse config', () => JSON.parse(raw));
227
+
196
228
  await measure('Server ready');
197
229
  // → [a] = Server ready
198
230
  ```
199
231
 
200
- ## Configure
232
+ ## ⚙️ Configuration
201
233
 
202
234
  ```typescript
203
235
  import { configure } from 'measure-fn';
@@ -205,7 +237,9 @@ import { configure } from 'measure-fn';
205
237
  configure({
206
238
  silent: true, // suppress all output
207
239
  timestamps: true, // prepend [HH:MM:SS.mmm]
208
- maxResultLength: 200, // truncate results (default: 80)
240
+ maxResultLength: 200, // truncate results (default: 200, 0 = unlimited)
241
+ dotEndLabel: false, // show full label on end lines (default: true = dots)
242
+ dotChar: '.', // character for dot fill (default: '·')
209
243
  logger: (event) => { // custom event handler
210
244
  myTelemetry.track(event);
211
245
  }
@@ -216,12 +250,12 @@ Env vars: `MEASURE_SILENT=1`, `MEASURE_TIMESTAMPS=1`
216
250
 
217
251
  ## Output Format
218
252
 
219
- | Symbol | Meaning |
220
- |--------|---------|
221
- | `[a] ...` | started |
222
- | `[a] ✓` | success |
223
- | `[a] ✗` | error |
224
- | `[a] =` | annotation |
253
+ | Symbol | Meaning | Example |
254
+ |--------|---------|---------|
255
+ | `...` | Started | `[a] ... Fetch users` |
256
+ | `···` | Success | `[a] ··········· 86ms [...]` |
257
+ | `✗` | Error | `[a] ··········· (Network Error)` |
258
+ | `=` | Annotation | `[a] = Server ready` |
225
259
 
226
260
  IDs encode hierarchy: `[a]` → root, `[a-a]` → first child, `[a-b]` → second child.
227
261
 
package/index.test.ts CHANGED
@@ -21,7 +21,7 @@ function captureConsole() {
21
21
  describe("measure (async)", () => {
22
22
  beforeEach(() => {
23
23
  resetCounter();
24
- configure({ silent: false, logger: null, timestamps: false, maxResultLength: 80 });
24
+ configure({ silent: false, logger: null, timestamps: false, maxResultLength: 200 });
25
25
  });
26
26
 
27
27
  test("runs and returns result", async () => {
@@ -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 () => {
@@ -115,7 +115,7 @@ describe("measure (async)", () => {
115
115
  describe("measureSync", () => {
116
116
  beforeEach(() => {
117
117
  resetCounter();
118
- configure({ silent: false, logger: null, timestamps: false, maxResultLength: 80 });
118
+ configure({ silent: false, logger: null, timestamps: false, maxResultLength: 200 });
119
119
  });
120
120
 
121
121
  test("leaf = single line with result", () => {
@@ -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", () => {
@@ -178,7 +178,7 @@ describe("formatDuration", () => {
178
178
  describe("safeStringify", () => {
179
179
  beforeEach(() => {
180
180
  resetCounter();
181
- configure({ silent: false, logger: null, timestamps: false, maxResultLength: 80 });
181
+ configure({ silent: false, logger: null, timestamps: false, maxResultLength: 200 });
182
182
  });
183
183
 
184
184
  test("circular handled", () => {
@@ -247,7 +247,7 @@ describe("timestamps", () => {
247
247
  describe("configurable truncation", () => {
248
248
  beforeEach(() => {
249
249
  resetCounter();
250
- configure({ silent: false, logger: null, timestamps: false, maxResultLength: 80 });
250
+ configure({ silent: false, logger: null, timestamps: false, maxResultLength: 200 });
251
251
  });
252
252
 
253
253
  test("shorter truncation", () => {
@@ -265,6 +265,52 @@ describe("configurable truncation", () => {
265
265
  out.restore();
266
266
  expect(out.logs[0]).not.toContain("…");
267
267
  });
268
+
269
+ test("per-label maxResultLength overrides global", () => {
270
+ configure({ maxResultLength: 500 });
271
+ const out = captureConsole();
272
+ measureSync({ label: "op", maxResultLength: 15 }, () => ({ d: "x".repeat(50) }));
273
+ out.restore();
274
+ expect(out.logs[0]).toContain("…");
275
+ });
276
+
277
+ test("per-label maxResultLength inherits to children", () => {
278
+ const out = captureConsole();
279
+ measureSync({ label: "parent", maxResultLength: 15 }, (m) => {
280
+ m("child", () => ({ d: "x".repeat(50) }));
281
+ return 1;
282
+ });
283
+ out.restore();
284
+ const childLine = out.logs[1]; // [a-a] line
285
+ expect(childLine).toContain("…");
286
+ });
287
+
288
+ test("child can override inherited maxResultLength", () => {
289
+ const out = captureConsole();
290
+ measureSync({ label: "parent", maxResultLength: 15 }, (m) => {
291
+ m({ label: "child", maxResultLength: 500 }, () => ({ d: "x".repeat(50) }));
292
+ return 1;
293
+ });
294
+ out.restore();
295
+ const childLine = out.logs[1]; // child line
296
+ expect(childLine).not.toContain("…");
297
+ });
298
+
299
+ test("maxResultLength: 0 means unlimited", () => {
300
+ const out = captureConsole();
301
+ measureSync({ label: "op", maxResultLength: 0 }, () => ({ d: "x".repeat(500) }));
302
+ out.restore();
303
+ expect(out.logs[0]).not.toContain("…");
304
+ expect(out.logs[0]).toContain("x".repeat(500));
305
+ });
306
+
307
+ test("maxResultLength not shown in meta", () => {
308
+ const out = captureConsole();
309
+ measureSync({ label: "op", maxResultLength: 50 }, (m) => { return 1; });
310
+ out.restore();
311
+ expect(out.logs[0]).toBe("[a] ... op");
312
+ expect(out.logs[0]).not.toContain("maxResultLength");
313
+ });
268
314
  });
269
315
 
270
316
  // ─── Budget ──────────────────────────────────────────────────────────
@@ -354,7 +400,7 @@ describe("measure.retry", () => {
354
400
  const r = await measure.retry("op", { attempts: 3, delay: 10 }, async () => 42);
355
401
  out.restore();
356
402
  expect(r).toBe(42);
357
- expect(out.logs[1]).toContain("");
403
+ expect(out.logs[1]).not.toContain("");
358
404
  expect(out.logs[0]).toContain("[1/3]");
359
405
  });
360
406
 
@@ -368,7 +414,7 @@ describe("measure.retry", () => {
368
414
  out.restore();
369
415
  expect(r).toBe("ok");
370
416
  expect(out.logs.filter(l => l.includes("✗")).length).toBe(2);
371
- expect(out.logs.filter(l => l.includes("")).length).toBe(1);
417
+ expect(out.logs.filter(l => !l.includes("") && !l.includes("...")).length).toBe(1);
372
418
  });
373
419
 
374
420
  test("all attempts exhausted returns null", async () => {
@@ -589,7 +635,7 @@ describe("measure.wrap", () => {
589
635
  out.restore();
590
636
  expect(r).toBe(42);
591
637
  expect(out.logs[0]).toBe("[a] ... double");
592
- expect(out.logs[1]).toContain("");
638
+ expect(out.logs[1]).not.toContain("");
593
639
  });
594
640
 
595
641
  test("multiple calls get sequential IDs", async () => {
@@ -610,7 +656,7 @@ describe("measure.wrap", () => {
610
656
  const r = wrapped(7);
611
657
  out.restore();
612
658
  expect(r).toBe(21);
613
- expect(out.logs[0]).toContain("✓ triple");
659
+ expect(out.logs[0]).toContain("······");
614
660
  });
615
661
  });
616
662
 
@@ -628,7 +674,6 @@ describe("measure.batch", () => {
628
674
  out.restore();
629
675
  expect(results).toEqual([2, 4, 6]);
630
676
  expect(out.logs[0]).toContain("3 items");
631
- expect(out.logs.at(-1)).toContain("✓");
632
677
  expect(out.logs.at(-1)).toContain("3/3 ok");
633
678
  });
634
679
 
package/index.ts CHANGED
@@ -12,9 +12,10 @@ const toAlpha = (num: number): string => {
12
12
 
13
13
  // ─── Safe Stringify ──────────────────────────────────────────────────
14
14
 
15
- let maxResultLen = 80;
15
+ let maxResultLen = 200;
16
16
 
17
- export const safeStringify = (value: unknown): string => {
17
+ export const safeStringify = (value: unknown, limit?: number): string => {
18
+ const cap = limit ?? maxResultLen;
18
19
  if (value === undefined) return '';
19
20
  if (value === null) return 'null';
20
21
  if (typeof value === 'number' || typeof value === 'boolean') return String(value);
@@ -22,7 +23,8 @@ export const safeStringify = (value: unknown): string => {
22
23
  if (typeof value === 'symbol') return value.toString();
23
24
  if (typeof value === 'string') {
24
25
  const q = JSON.stringify(value);
25
- return q.length > maxResultLen ? q.slice(0, maxResultLen - 1) + '…"' : q;
26
+ if (cap === 0) return q;
27
+ return q.length > cap ? q.slice(0, cap - 1) + '…"' : q;
26
28
  }
27
29
  try {
28
30
  const seen = new WeakSet();
@@ -35,7 +37,8 @@ export const safeStringify = (value: unknown): string => {
35
37
  if (typeof val === 'bigint') return `${val}n`;
36
38
  return val;
37
39
  });
38
- return str.length > maxResultLen ? str.slice(0, maxResultLen) + '…' : str;
40
+ if (cap === 0) return str;
41
+ return str.length > cap ? str.slice(0, cap) + '…' : str;
39
42
  } catch {
40
43
  return String(value);
41
44
  }
@@ -54,7 +57,7 @@ const formatDuration = (ms: number): string => {
54
57
  // ─── Timestamps ──────────────────────────────────────────────────────
55
58
 
56
59
  let timestamps =
57
- process.env.MEASURE_TIMESTAMPS === '1' || process.env.MEASURE_TIMESTAMPS === 'true';
60
+ typeof process !== 'undefined' && (process.env.MEASURE_TIMESTAMPS === '1' || process.env.MEASURE_TIMESTAMPS === 'true');
58
61
 
59
62
  const ts = (): string => {
60
63
  if (!timestamps) return '';
@@ -78,12 +81,16 @@ export type MeasureEvent = {
78
81
  error?: unknown;
79
82
  meta?: Record<string, unknown>;
80
83
  budget?: number;
84
+ maxResultLength?: number;
81
85
  };
82
86
 
83
87
  // ─── Configuration ───────────────────────────────────────────────────
84
88
 
85
89
  export let silent =
86
- process.env.MEASURE_SILENT === '1' || process.env.MEASURE_SILENT === 'true';
90
+ typeof process !== 'undefined' && (process.env.MEASURE_SILENT === '1' || process.env.MEASURE_SILENT === 'true');
91
+
92
+ let dotEndLabel = true;
93
+ let dotChar = '·';
87
94
 
88
95
  export let logger: ((event: MeasureEvent) => void) | null = null;
89
96
 
@@ -92,6 +99,8 @@ export type ConfigureOpts = {
92
99
  logger?: ((event: MeasureEvent) => void) | null;
93
100
  timestamps?: boolean;
94
101
  maxResultLength?: number;
102
+ dotEndLabel?: boolean;
103
+ dotChar?: string;
95
104
  };
96
105
 
97
106
  export const configure = (opts: ConfigureOpts) => {
@@ -99,6 +108,8 @@ export const configure = (opts: ConfigureOpts) => {
99
108
  if (opts.logger !== undefined) logger = opts.logger;
100
109
  if (opts.timestamps !== undefined) timestamps = opts.timestamps;
101
110
  if (opts.maxResultLength !== undefined) maxResultLen = opts.maxResultLength;
111
+ if (opts.dotEndLabel !== undefined) dotEndLabel = opts.dotEndLabel;
112
+ if (opts.dotChar !== undefined) dotChar = opts.dotChar;
102
113
  };
103
114
 
104
115
  // ─── Shared Helpers ──────────────────────────────────────────────────
@@ -121,11 +132,18 @@ const extractTimeout = (actionInternal: string | object): number | undefined =>
121
132
  return undefined;
122
133
  };
123
134
 
135
+ const extractMaxResultLength = (actionInternal: string | object): number | undefined => {
136
+ if (typeof actionInternal !== 'object' || actionInternal === null) return undefined;
137
+ if ('maxResultLength' in actionInternal) return Number((actionInternal as any).maxResultLength);
138
+ return undefined;
139
+ };
140
+
124
141
  const extractMeta = (actionInternal: string | object): Record<string, unknown> | undefined => {
125
142
  if (typeof actionInternal !== 'object' || actionInternal === null) return undefined;
126
143
  const details = { ...actionInternal };
127
144
  if ('label' in details) delete (details as any).label;
128
145
  if ('budget' in details) delete (details as any).budget;
146
+ if ('maxResultLength' in details) delete (details as any).maxResultLength;
129
147
  if (Object.keys(details).length === 0) return undefined;
130
148
  return details as Record<string, unknown>;
131
149
  };
@@ -157,20 +175,22 @@ const defaultLogger = (event: MeasureEvent, prefix?: string) => {
157
175
  console.log(`${t}${id} ... ${event.label}${formatMeta(event.meta)}`);
158
176
  break;
159
177
  case 'success': {
160
- const resultStr = event.result !== undefined ? safeStringify(event.result) : '';
178
+ const endLabel = dotEndLabel ? dotChar.repeat(event.label.length) : event.label;
179
+ const resultStr = event.result !== undefined ? safeStringify(event.result, event.maxResultLength) : '';
161
180
  const arrow = resultStr ? ` → ${resultStr}` : '';
162
181
  const budgetWarn = event.budget && event.duration! > event.budget
163
182
  ? ` ⚠ OVER BUDGET (${formatDuration(event.budget)})`
164
183
  : '';
165
- console.log(`${t}${id} ${event.label} ${formatDuration(event.duration!)}${arrow}${budgetWarn}`);
184
+ console.log(`${t}${id} ${endLabel} ${formatDuration(event.duration!)}${arrow}${budgetWarn}`);
166
185
  break;
167
186
  }
168
187
  case 'error': {
188
+ const endLabel = dotEndLabel ? dotChar.repeat(event.label.length) : event.label;
169
189
  const errorMsg = event.error instanceof Error ? event.error.message : String(event.error);
170
190
  const budgetWarn = event.budget && event.duration! > event.budget
171
191
  ? ` ⚠ OVER BUDGET (${formatDuration(event.budget)})`
172
192
  : '';
173
- console.log(`${t}${id} ✗ ${event.label} ${formatDuration(event.duration!)} (${errorMsg})${budgetWarn}`);
193
+ console.log(`${t}${id} ✗ ${endLabel} ${formatDuration(event.duration!)} (${errorMsg})${budgetWarn}`);
174
194
  if (event.error instanceof Error) {
175
195
  console.error(`${id}`, event.error.stack ?? event.error.message);
176
196
  if (event.error.cause) {
@@ -220,8 +240,9 @@ const createNestedResolver = (
220
240
  fullIdChain: string[],
221
241
  childCounterRef: { value: number },
222
242
  depth: number,
223
- resolver: <U>(fn: any, action: any, chain: (string | number)[], depth: number, onError?: (error: unknown) => any) => Promise<U | null> | (U | null),
224
- prefix?: string
243
+ resolver: <U>(fn: any, action: any, chain: (string | number)[], depth: number, onError?: (error: unknown) => any, inheritedMaxLen?: number) => Promise<U | null> | (U | null),
244
+ prefix?: string,
245
+ inheritedMaxLen?: number
225
246
  ) => {
226
247
  return (...args: any[]) => {
227
248
  const label = args[0];
@@ -230,7 +251,7 @@ const createNestedResolver = (
230
251
 
231
252
  if (typeof fn === 'function') {
232
253
  const childParentChain = [...fullIdChain, childCounterRef.value++];
233
- return resolver(fn, label, childParentChain, depth + 1, typeof onError === 'function' ? onError : undefined);
254
+ return resolver(fn, label, childParentChain, depth + 1, typeof onError === 'function' ? onError : undefined, inheritedMaxLen);
234
255
  } else {
235
256
  emit({
236
257
  type: 'annotation',
@@ -263,13 +284,16 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
263
284
  actionInternal: string | object,
264
285
  parentIdChain: (string | number)[],
265
286
  depth: number,
266
- onError?: (error: unknown) => any
287
+ onError?: (error: unknown) => any,
288
+ inheritedMaxLen?: number
267
289
  ): Promise<U | null> => {
268
290
  const start = performance.now();
269
291
  const childCounterRef = { value: 0 };
270
292
  const label = buildActionLabel(actionInternal);
271
293
  const budget = extractBudget(actionInternal);
272
294
  const timeout = extractTimeout(actionInternal);
295
+ const localMaxLen = extractMaxResultLength(actionInternal);
296
+ const effectiveMaxLen = localMaxLen ?? inheritedMaxLen;
273
297
 
274
298
  const currentId = toAlpha(parentIdChain.pop() ?? 0);
275
299
  const fullIdChain = [...parentIdChain, currentId];
@@ -283,7 +307,7 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
283
307
  meta: extractMeta(actionInternal),
284
308
  }, prefix);
285
309
 
286
- const measureForNextLevel = createNestedResolver(true, fullIdChain, childCounterRef, depth, _measureInternal, prefix);
310
+ const measureForNextLevel = createNestedResolver(true, fullIdChain, childCounterRef, depth, _measureInternal, prefix, effectiveMaxLen);
287
311
 
288
312
  try {
289
313
  let result: U;
@@ -298,17 +322,17 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
298
322
  result = await fnInternal(measureForNextLevel as MeasureFn);
299
323
  }
300
324
  const duration = performance.now() - start;
301
- emit({ type: 'success', id: idStr, label, depth, duration, result, budget }, prefix);
325
+ emit({ type: 'success', id: idStr, label, depth, duration, result, budget, maxResultLength: effectiveMaxLen }, prefix);
302
326
  return result;
303
327
  } catch (error) {
304
328
  const duration = performance.now() - start;
305
- emit({ type: 'error', id: idStr, label, depth, duration, error, budget }, prefix);
329
+ emit({ type: 'error', id: idStr, label, depth, duration, error, budget, maxResultLength: effectiveMaxLen }, prefix);
306
330
  _lastError = error;
307
331
  if (onError) {
308
332
  try {
309
333
  return onError(error);
310
334
  } catch (onErrorError) {
311
- emit({ type: 'error', id: idStr, label: `${label} (onError)`, depth, duration: performance.now() - start, error: onErrorError, budget }, prefix);
335
+ emit({ type: 'error', id: idStr, label: `${label} (onError)`, depth, duration: performance.now() - start, error: onErrorError, budget, maxResultLength: effectiveMaxLen }, prefix);
312
336
  _lastError = onErrorError;
313
337
  return null;
314
338
  }
@@ -321,13 +345,17 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
321
345
  fnInternal: (measure: MeasureSyncFn) => U,
322
346
  actionInternal: string | object,
323
347
  parentIdChain: (string | number)[],
324
- depth: number
348
+ depth: number,
349
+ _onError?: undefined,
350
+ inheritedMaxLen?: number
325
351
  ): U | null => {
326
352
  const start = performance.now();
327
353
  const childCounterRef = { value: 0 };
328
354
  const label = buildActionLabel(actionInternal);
329
355
  const hasNested = fnInternal.length > 0;
330
356
  const budget = extractBudget(actionInternal);
357
+ const localMaxLen = extractMaxResultLength(actionInternal);
358
+ const effectiveMaxLen = localMaxLen ?? inheritedMaxLen;
331
359
 
332
360
  const currentId = toAlpha(parentIdChain.pop() ?? 0);
333
361
  const fullIdChain = [...parentIdChain, currentId];
@@ -343,16 +371,16 @@ const createMeasureImpl = (prefix?: string, counterRef?: { value: number }) => {
343
371
  }, prefix);
344
372
  }
345
373
 
346
- const measureForNextLevel = createNestedResolver(false, fullIdChain, childCounterRef, depth, _measureInternalSync, prefix);
374
+ const measureForNextLevel = createNestedResolver(false, fullIdChain, childCounterRef, depth, _measureInternalSync, prefix, effectiveMaxLen);
347
375
 
348
376
  try {
349
377
  const result = fnInternal(measureForNextLevel as MeasureSyncFn);
350
378
  const duration = performance.now() - start;
351
- emit({ type: 'success', id: idStr, label, depth, duration, result, budget }, prefix);
379
+ emit({ type: 'success', id: idStr, label, depth, duration, result, budget, maxResultLength: effectiveMaxLen }, prefix);
352
380
  return result;
353
381
  } catch (error) {
354
382
  const duration = performance.now() - start;
355
- emit({ type: 'error', id: idStr, label, depth, duration, error, budget }, prefix);
383
+ emit({ type: 'error', id: idStr, label, depth, duration, error, budget, maxResultLength: effectiveMaxLen }, prefix);
356
384
  _lastError = error;
357
385
  return null;
358
386
  }
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.6.0",
6
+ "version": "3.8.0",
7
7
  "type": "module",
8
8
  "private": false,
9
9
  "description": "Zero-dependency function performance measurement with hierarchical logging",
@@ -30,4 +30,4 @@
30
30
  "test": "bun test",
31
31
  "example": "bun run example.ts"
32
32
  }
33
- }
33
+ }