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.
- package/README.md +135 -101
- package/index.test.ts +59 -14
- package/index.ts +49 -21
- 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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
|
35
|
+
import { measure } from 'measure-fn';
|
|
13
36
|
|
|
14
37
|
const users = await measure('Fetch users', () => fetchUsers());
|
|
15
|
-
|
|
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
|
-
|
|
43
|
+
```sh
|
|
44
|
+
npm install measure-fn
|
|
45
|
+
# or bun add / pnpm add / yarn add
|
|
46
|
+
```
|
|
25
47
|
|
|
26
|
-
|
|
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
|
-
|
|
50
|
+
Every `measure` call automatically:
|
|
32
51
|
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
const user = await measure('Fetch user', () => fetchUser(1));
|
|
37
|
-
// throws → logs ✗, user = null
|
|
38
|
-
```
|
|
56
|
+
## 🌳 Nested Calls (Tracing)
|
|
39
57
|
|
|
40
|
-
Pass `
|
|
58
|
+
Pass a child `m` function to get hierarchical APM-like tracing for free:
|
|
41
59
|
|
|
42
60
|
```typescript
|
|
43
|
-
|
|
44
|
-
(
|
|
45
|
-
);
|
|
46
|
-
|
|
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
|
-
|
|
68
|
+
```
|
|
69
|
+
[a] ... Pipeline
|
|
70
|
+
[a-a] ·········· 82ms → {"id":1}
|
|
71
|
+
[a-b] ··········· 45ms → [...]
|
|
72
|
+
[a] ········ 128ms
|
|
73
|
+
```
|
|
50
74
|
|
|
51
|
-
|
|
75
|
+
Parallel execution works cleanly too:
|
|
52
76
|
|
|
53
77
|
```typescript
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
70
|
-
//
|
|
91
|
+
const user = await measure('Fetch user', () => fetchUser(1));
|
|
92
|
+
// If it throws → logs ✗, user = null
|
|
71
93
|
```
|
|
72
94
|
|
|
73
|
-
|
|
95
|
+
**Custom Fallbacks:** Pass `onError` as the 3rd argument:
|
|
74
96
|
|
|
75
97
|
```typescript
|
|
76
|
-
const
|
|
77
|
-
(error) =>
|
|
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
|
-
|
|
104
|
+
If the `onError` fallback itself throws, that's also safely caught and returns `null`. measure never crashes.
|
|
82
105
|
|
|
83
|
-
|
|
106
|
+
**Fail-Fast (`.assert`):** Use `.assert()` when you need a guaranteed non-null result:
|
|
84
107
|
|
|
85
108
|
```typescript
|
|
86
|
-
await measure(
|
|
87
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
await measure({ label: 'Query', budget: 100, timeout: 5000 }, () => query());
|
|
94
|
-
```
|
|
119
|
+
## 🚦 Timeouts & Budgets
|
|
95
120
|
|
|
96
|
-
|
|
121
|
+
The first argument can be a label string, or an options object:
|
|
97
122
|
|
|
98
|
-
|
|
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('
|
|
102
|
-
|
|
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
|
-
|
|
140
|
+
**Budget** (warn):
|
|
116
141
|
|
|
117
142
|
```typescript
|
|
118
|
-
await measure('
|
|
119
|
-
|
|
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
|
-
|
|
147
|
+
Combine both — budget warns early, timeout enforces a hard stop:
|
|
127
148
|
|
|
128
|
-
|
|
149
|
+
```typescript
|
|
150
|
+
await measure({ label: 'Query', budget: 100, timeout: 5000 }, () => query());
|
|
151
|
+
```
|
|
129
152
|
|
|
130
|
-
|
|
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
|
|
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
|
-
|
|
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]
|
|
149
|
-
await getUser(2); // → [b]
|
|
168
|
+
await getUser(1); // → [a] ········ 82ms
|
|
169
|
+
await getUser(2); // → [b] ········ 75ms
|
|
150
170
|
```
|
|
151
171
|
|
|
152
|
-
### `measure.batch(label, items, fn, opts?)`
|
|
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]
|
|
182
|
+
// → [a] ················· 5.3s → "500/500 ok"
|
|
161
183
|
```
|
|
162
184
|
|
|
163
|
-
### `measure.retry(label, opts, fn)`
|
|
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]
|
|
194
|
+
// → [b] ················· 89ms → {"status":"ok"}
|
|
171
195
|
```
|
|
172
196
|
|
|
173
|
-
### `measure.timed(label, fn?)`
|
|
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)`
|
|
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]
|
|
190
|
-
// → [api:a]
|
|
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
|
-
##
|
|
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:
|
|
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]
|
|
222
|
-
| `[a]
|
|
223
|
-
| `[a]
|
|
224
|
-
| `[a]
|
|
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:
|
|
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
|
|
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\]
|
|
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:
|
|
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\]
|
|
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\]
|
|
135
|
-
expect(out.logs[2]).toMatch(/\[a\]
|
|
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:
|
|
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:
|
|
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("
|
|
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("
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
|
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} ✗ ${
|
|
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
|
+
"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
|
+
}
|