measure-fn 3.6.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.
Files changed (4) hide show
  1. package/README.md +133 -100
  2. package/index.test.ts +9 -10
  3. package/index.ts +13 -4
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -2,154 +2,175 @@
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
+ | any other | `any` | Logged inline as context metadata |
129
+
130
+ **Timeout** (enforce):
99
131
 
100
132
  ```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
- });
133
+ const data = await measure({ label: 'Slow API', timeout: 5000 }, () => fetchSlowApi());
134
+ // > 5s Slow API 5.0s (Timeout (5.0s)), returns null
106
135
  ```
107
136
 
108
- ```
109
- [a] ... Pipeline
110
- [a-a] ✓ Fetch user 82ms → {"id":1}
111
- [a-b] ✓ Fetch posts 45ms → [...]
112
- [a] ✓ Pipeline 128ms
113
- ```
137
+ Works with `onError` fallback too.
114
138
 
115
- Parallel:
139
+ **Budget** (warn):
116
140
 
117
141
  ```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
- });
142
+ await measure({ label: 'DB query', budget: 100 }, () => db.query('...'));
143
+ // [a] ········ 245ms → [...] OVER BUDGET (100ms)
124
144
  ```
125
145
 
126
- ## Label Object
146
+ Combine both — budget warns early, timeout enforces a hard stop:
127
147
 
128
- The first argument can be a string or an object. Object fields:
148
+ ```typescript
149
+ await measure({ label: 'Query', budget: 100, timeout: 5000 }, () => query());
150
+ ```
129
151
 
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 |
152
+ **Metadata context:**
136
153
 
137
154
  ```typescript
138
- await measure({ label: 'Fetch user', userId: 1, timeout: 3000 }, () => fetchUser(1));
155
+ await measure({ label: 'Fetch user', userId: 1 }, () => fetchUser(1));
139
156
  // → [a] ... Fetch user (userId=1)
140
157
  ```
141
158
 
142
- ## Extensions
159
+ ## 🧰 Extensions
160
+
161
+ ### `measure.wrap(label, fn)`
143
162
 
144
- ### `measure.wrap(label, fn)` — wrap once, measure every call
163
+ Wrap a function once, measure every time it's called:
145
164
 
146
165
  ```typescript
147
166
  const getUser = measure.wrap('Get user', fetchUser);
148
- await getUser(1); // → [a] Get user 82ms
149
- await getUser(2); // → [b] Get user 75ms
167
+ await getUser(1); // → [a] ········ 82ms
168
+ await getUser(2); // → [b] ········ 75ms
150
169
  ```
151
170
 
152
- ### `measure.batch(label, items, fn, opts?)` — array with progress
171
+ ### `measure.batch(label, items, fn, opts?)`
172
+
173
+ Process arrays with built-in progress logs:
153
174
 
154
175
  ```typescript
155
176
  const results = await measure.batch('Process', userIds, async (id) => {
@@ -157,26 +178,32 @@ const results = await measure.batch('Process', userIds, async (id) => {
157
178
  }, { every: 100 });
158
179
  // → [a] ... Process (500 items)
159
180
  // → [a] = 100/500 (1.2s, 83/s)
160
- // → [a] Process (500 items) 5.3s → "500/500 ok"
181
+ // → [a] ················· 5.3s → "500/500 ok"
161
182
  ```
162
183
 
163
- ### `measure.retry(label, opts, fn)` — retry with backoff
184
+ ### `measure.retry(label, opts, fn)`
185
+
186
+ Automatic retries with delay and backoff:
164
187
 
165
188
  ```typescript
166
189
  const result = await measure.retry('Flaky API', {
167
190
  attempts: 3, delay: 1000, backoff: 2
168
191
  }, () => fetchFlakyApi());
169
192
  // → [a] ✗ Flaky API [1/3] 102ms (timeout)
170
- // → [b] Flaky API [2/3] 89ms → {"status":"ok"}
193
+ // → [b] ················· 89ms → {"status":"ok"}
171
194
  ```
172
195
 
173
- ### `measure.timed(label, fn?)` — get duration programmatically
196
+ ### `measure.timed(label, fn?)`
197
+
198
+ Get duration programmatically alongside the result:
174
199
 
175
200
  ```typescript
176
201
  const { result, duration } = await measure.timed('Fetch', () => fetchUsers());
177
202
  ```
178
203
 
179
- ### `createMeasure(prefix)` — scoped instance
204
+ ### `createMeasure(prefix)`
205
+
206
+ Scoped instances with custom prefixes:
180
207
 
181
208
  ```typescript
182
209
  const api = createMeasure('api');
@@ -186,18 +213,22 @@ await api.measure('GET /users', async () => {
186
213
  return await db.measure('SELECT', () => query('...'));
187
214
  });
188
215
  // → [api:a] ... GET /users
189
- // → [db:a] SELECT 44ms
190
- // → [api:a] GET /users 45ms
216
+ // → [db:a] ······ 44ms
217
+ // → [api:a] ·········· 45ms
191
218
  ```
192
219
 
193
- ### Annotations
220
+ ### Annotations & Sync
194
221
 
195
222
  ```typescript
223
+ import { measureSync } from 'measure-fn';
224
+
225
+ const config = measureSync('Parse config', () => JSON.parse(raw));
226
+
196
227
  await measure('Server ready');
197
228
  // → [a] = Server ready
198
229
  ```
199
230
 
200
- ## Configure
231
+ ## ⚙️ Configuration
201
232
 
202
233
  ```typescript
203
234
  import { configure } from 'measure-fn';
@@ -206,6 +237,8 @@ configure({
206
237
  silent: true, // suppress all output
207
238
  timestamps: true, // prepend [HH:MM:SS.mmm]
208
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: '·')
209
242
  logger: (event) => { // custom event handler
210
243
  myTelemetry.track(event);
211
244
  }
@@ -216,12 +249,12 @@ Env vars: `MEASURE_SILENT=1`, `MEASURE_TIMESTAMPS=1`
216
249
 
217
250
  ## Output Format
218
251
 
219
- | Symbol | Meaning |
220
- |--------|---------|
221
- | `[a] ...` | started |
222
- | `[a] ✓` | success |
223
- | `[a] ✗` | error |
224
- | `[a] =` | annotation |
252
+ | Symbol | Meaning | Example |
253
+ |--------|---------|---------|
254
+ | `...` | Started | `[a] ... Fetch users` |
255
+ | `···` | Success | `[a] ··········· 86ms [...]` |
256
+ | `✗` | Error | `[a] ··········· (Network Error)` |
257
+ | `=` | Annotation | `[a] = Server ready` |
225
258
 
226
259
  IDs encode hierarchy: `[a]` → root, `[a-a]` → first child, `[a-b]` → second child.
227
260
 
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 () => {
@@ -589,7 +589,7 @@ describe("measure.wrap", () => {
589
589
  out.restore();
590
590
  expect(r).toBe(42);
591
591
  expect(out.logs[0]).toBe("[a] ... double");
592
- expect(out.logs[1]).toContain("");
592
+ expect(out.logs[1]).not.toContain("");
593
593
  });
594
594
 
595
595
  test("multiple calls get sequential IDs", async () => {
@@ -610,7 +610,7 @@ describe("measure.wrap", () => {
610
610
  const r = wrapped(7);
611
611
  out.restore();
612
612
  expect(r).toBe(21);
613
- expect(out.logs[0]).toContain("✓ triple");
613
+ expect(out.logs[0]).toContain("······");
614
614
  });
615
615
  });
616
616
 
@@ -628,7 +628,6 @@ describe("measure.batch", () => {
628
628
  out.restore();
629
629
  expect(results).toEqual([2, 4, 6]);
630
630
  expect(out.logs[0]).toContain("3 items");
631
- expect(out.logs.at(-1)).toContain("✓");
632
631
  expect(out.logs.at(-1)).toContain("3/3 ok");
633
632
  });
634
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 ──────────────────────────────────────────────────
@@ -157,20 +164,22 @@ const defaultLogger = (event: MeasureEvent, prefix?: string) => {
157
164
  console.log(`${t}${id} ... ${event.label}${formatMeta(event.meta)}`);
158
165
  break;
159
166
  case 'success': {
167
+ const endLabel = dotEndLabel ? dotChar.repeat(event.label.length) : event.label;
160
168
  const resultStr = event.result !== undefined ? safeStringify(event.result) : '';
161
169
  const arrow = resultStr ? ` → ${resultStr}` : '';
162
170
  const budgetWarn = event.budget && event.duration! > event.budget
163
171
  ? ` ⚠ OVER BUDGET (${formatDuration(event.budget)})`
164
172
  : '';
165
- console.log(`${t}${id} ${event.label} ${formatDuration(event.duration!)}${arrow}${budgetWarn}`);
173
+ console.log(`${t}${id} ${endLabel} ${formatDuration(event.duration!)}${arrow}${budgetWarn}`);
166
174
  break;
167
175
  }
168
176
  case 'error': {
177
+ const endLabel = dotEndLabel ? dotChar.repeat(event.label.length) : event.label;
169
178
  const errorMsg = event.error instanceof Error ? event.error.message : String(event.error);
170
179
  const budgetWarn = event.budget && event.duration! > event.budget
171
180
  ? ` ⚠ OVER BUDGET (${formatDuration(event.budget)})`
172
181
  : '';
173
- console.log(`${t}${id} ✗ ${event.label} ${formatDuration(event.duration!)} (${errorMsg})${budgetWarn}`);
182
+ console.log(`${t}${id} ✗ ${endLabel} ${formatDuration(event.duration!)} (${errorMsg})${budgetWarn}`);
174
183
  if (event.error instanceof Error) {
175
184
  console.error(`${id}`, event.error.stack ?? event.error.message);
176
185
  if (event.error.cause) {
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.7.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
+ }