measure-fn 2.0.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 +285 -0
- package/SKILL.md +155 -0
- package/bun.lock +25 -0
- package/example.ts +73 -0
- package/index.test.ts +277 -0
- package/index.ts +219 -0
- package/package.json +32 -0
- package/tsconfig.json +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# measure-fn
|
|
2
|
+
|
|
3
|
+
**Stop writing blind code.** Every function you write either succeeds or fails, takes some amount of time, and lives inside a larger flow. `measure-fn` makes all of that visible — automatically, hierarchically, beautifully.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
> [a] Build Dashboard
|
|
7
|
+
> [a-a] Fetch users
|
|
8
|
+
< [a-a] ✓ 245.12ms
|
|
9
|
+
> [a-b] Process users
|
|
10
|
+
> [a-b-a] Enrich user (userId=1)
|
|
11
|
+
< [a-b-a] ✓ 12.34ms
|
|
12
|
+
> [a-b-b] Enrich user (userId=2)
|
|
13
|
+
< [a-b-b] ✓ 11.89ms
|
|
14
|
+
< [a-b] ✓ 25.67ms
|
|
15
|
+
> [a-c] Generate report
|
|
16
|
+
< [a-c] ✓ 8.91ms
|
|
17
|
+
< [a] ✓ 281.23ms
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
No setup. No dashboards to configure. No telemetry SDKs. Just wrap your functions and your entire program becomes observable.
|
|
21
|
+
|
|
22
|
+
## Why Structure Your Programs With measure-fn
|
|
23
|
+
|
|
24
|
+
Most codebases are **opaque by default**. When something is slow, you add `console.time`. When something crashes, you add `try/catch`. When you need tracing, you integrate a monitoring SDK. You bolt observability on *after* the problems arrive.
|
|
25
|
+
|
|
26
|
+
**measure-fn inverts this.** You structure your program with `measure` from the start, and you get:
|
|
27
|
+
|
|
28
|
+
- ⏱️ **Every operation timed** — no more "which step is slow?"
|
|
29
|
+
- 🌳 **Hierarchical trace** — see exactly how operations nest and compose
|
|
30
|
+
- 🛡️ **Errors caught and logged automatically** — with stack traces, causes, and unique IDs for every operation
|
|
31
|
+
- 📍 **Unique alphabetic IDs** — `[a-b-c]` tells you exactly which step in which pipeline failed
|
|
32
|
+
- 🔄 **Zero disruption** — errors return `null`, they never crash your program. Handle failures, don't fight them.
|
|
33
|
+
|
|
34
|
+
The result: your logs tell a **complete story**. You can read them top-to-bottom and understand exactly what happened, what took how long, and what failed — without adding a single breakpoint.
|
|
35
|
+
|
|
36
|
+
### The Philosophy: Programs as Measured Pipelines
|
|
37
|
+
|
|
38
|
+
Think of your program not as a flat list of statements, but as a **tree of measured operations**. Every meaningful unit of work — an API call, a database query, a transformation, a batch process — becomes a node in this tree.
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
// ❌ Typical blind code
|
|
42
|
+
async function processOrder(orderId: string) {
|
|
43
|
+
const order = await fetchOrder(orderId);
|
|
44
|
+
const inventory = await checkInventory(order.items);
|
|
45
|
+
await chargePayment(order);
|
|
46
|
+
await shipOrder(order);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ✅ Measured code — observable from day one
|
|
50
|
+
async function processOrder(orderId: string) {
|
|
51
|
+
await measure({ label: 'Process Order', orderId }, async (m) => {
|
|
52
|
+
const order = await m('Fetch order', () => fetchOrder(orderId));
|
|
53
|
+
if (!order) return; // error already logged + traced
|
|
54
|
+
|
|
55
|
+
await m('Check inventory', () => checkInventory(order.items));
|
|
56
|
+
await m('Charge payment', () => chargePayment(order));
|
|
57
|
+
await m('Ship order', () => shipOrder(order));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The measured version costs you **one extra line per operation**. In return, you get timing, error isolation, hierarchical tracing, and structured logs — forever.
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
bun add measure-fn
|
|
68
|
+
# or
|
|
69
|
+
npm install measure-fn
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Quick Start
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
import { measure, measureSync } from 'measure-fn';
|
|
76
|
+
|
|
77
|
+
// Label first, function second — reads like a sentence
|
|
78
|
+
await measure('Fetch data', async () => {
|
|
79
|
+
const res = await fetch('https://api.example.com/data');
|
|
80
|
+
return res.json();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Sync operations work the same way
|
|
84
|
+
const config = measureSync('Parse config', () => {
|
|
85
|
+
return JSON.parse(configString);
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## API
|
|
90
|
+
|
|
91
|
+
### `measure(label, fn?)` — async
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
// Simple: wrap any async operation
|
|
95
|
+
const user = await measure('Fetch user', async () => {
|
|
96
|
+
return await fetchUser(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Nested: receive `m` for composing sub-operations into a tree
|
|
100
|
+
await measure('Pipeline', async (m) => {
|
|
101
|
+
const user = await m('Get user', () => fetchUser(1));
|
|
102
|
+
const posts = await m('Get posts', () => fetchPosts(user.id));
|
|
103
|
+
|
|
104
|
+
// Arbitrarily deep nesting
|
|
105
|
+
await m('Enrich posts', async (m2) => {
|
|
106
|
+
for (const post of posts) {
|
|
107
|
+
await m2({ label: 'Get comments', postId: post.id }, () =>
|
|
108
|
+
fetchComments(post.id)
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Annotation — log a marker without timing anything
|
|
115
|
+
await measure('checkpoint reached');
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### `measureSync(label, fn?)` — synchronous
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
const result = measureSync('Compute hash', () => {
|
|
122
|
+
return computeExpensiveHash(data);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Nested sync operations
|
|
126
|
+
measureSync('Build report', (m) => {
|
|
127
|
+
const data = m('Parse CSV', () => parseCSV(raw));
|
|
128
|
+
const summary = m('Summarize', () => summarize(data));
|
|
129
|
+
return summary;
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Label formats
|
|
134
|
+
|
|
135
|
+
Labels can be a string or an object with a `.label` property. Extra properties are logged as metadata — perfect for recording request IDs, user IDs, or any context:
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
await measure('Simple string label', async () => fetchUser(1));
|
|
139
|
+
|
|
140
|
+
await measure({ label: 'Fetch user', userId: 1, region: 'us-east' }, async () => fetchUser(1));
|
|
141
|
+
// Logs: > [a] Fetch user (userId=1 region="us-east")
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### `resetCounter()`
|
|
145
|
+
|
|
146
|
+
Resets the global alphabetic ID counter. Essential for deterministic test output:
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
import { resetCounter } from 'measure-fn';
|
|
150
|
+
beforeEach(() => resetCounter());
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Error Handling — Errors Are Data, Not Crashes
|
|
154
|
+
|
|
155
|
+
This is the most important design decision in `measure-fn`: **errors never propagate**. When a measured function throws:
|
|
156
|
+
|
|
157
|
+
1. The error is logged with `✗`, the duration, and the error message
|
|
158
|
+
2. The full stack trace and `cause` (if present) go to `console.error` with the operation's unique ID
|
|
159
|
+
3. The function returns `null`
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
const result = await measure('Risky operation', async () => {
|
|
163
|
+
throw new Error('Network timeout', { cause: { url: '/api', retries: 3 } });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// result === null — no crash, no try/catch needed
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
> [a] Risky operation
|
|
171
|
+
< [a] ✗ 2.31ms (Network timeout)
|
|
172
|
+
[a] Error: Network timeout
|
|
173
|
+
at ... (stack trace)
|
|
174
|
+
[a] Cause: { url: "/api", retries: 3 }
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
This makes your programs **resilient by default**. A failing sub-operation doesn't take down the parent — it returns `null` and you decide what to do:
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
await measure('User Pipeline', async (m) => {
|
|
181
|
+
const user = await m('Get user', () => fetchUser(999));
|
|
182
|
+
if (user === null) {
|
|
183
|
+
// The error was already logged with full context.
|
|
184
|
+
// Skip dependent operations gracefully.
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
await m('Get posts', () => fetchPosts(user.id));
|
|
188
|
+
});
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
No try/catch pyramids. No error-swallowing. Every error is captured, attributed to a specific operation ID, and visible in plain text.
|
|
192
|
+
|
|
193
|
+
## Output Format
|
|
194
|
+
|
|
195
|
+
| Prefix | Meaning |
|
|
196
|
+
|--------|---------|
|
|
197
|
+
| `>` | Operation started |
|
|
198
|
+
| `<` | Operation completed |
|
|
199
|
+
| `=` | Annotation (label-only, no timing) |
|
|
200
|
+
| `✓` | Success |
|
|
201
|
+
| `✗` | Error |
|
|
202
|
+
|
|
203
|
+
IDs are alphabetic and hierarchical: `[a]`, `[a-a]`, `[a-b]`, `[a-b-a]`, etc. After `z` it wraps: `aa`, `ab`, `ac`...
|
|
204
|
+
|
|
205
|
+
Every ID is a **unique address** for that operation in that execution. You can grep for `[a-c-b]` and find exactly one operation — making log analysis trivial.
|
|
206
|
+
|
|
207
|
+
## Real-World Patterns
|
|
208
|
+
|
|
209
|
+
### Server Request Handler
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
app.get('/api/dashboard', async (req, res) => {
|
|
213
|
+
const data = await measure({ label: 'GET /api/dashboard', ip: req.ip }, async (m) => {
|
|
214
|
+
const session = await m('Validate session', () => validateSession(req));
|
|
215
|
+
if (!session) return null;
|
|
216
|
+
|
|
217
|
+
const [users, stats] = await Promise.all([
|
|
218
|
+
m('Fetch users', () => db.users.all()),
|
|
219
|
+
m('Compute stats', () => computeStats()),
|
|
220
|
+
]);
|
|
221
|
+
|
|
222
|
+
return { users, stats };
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
res.json(data ?? { error: 'Failed' });
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Background Job
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
async function processBatch(batchId: string) {
|
|
233
|
+
await measure({ label: 'Process Batch', batchId }, async (m) => {
|
|
234
|
+
const items = await m('Load items', () => loadBatch(batchId));
|
|
235
|
+
if (!items) return;
|
|
236
|
+
|
|
237
|
+
await m('Transform', async (m2) => {
|
|
238
|
+
for (const item of items) {
|
|
239
|
+
await m2({ label: 'Process item', itemId: item.id }, () =>
|
|
240
|
+
transform(item)
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await m('Save results', () => saveBatch(batchId, items));
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### CLI Tool
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
measureSync('Build Project', (m) => {
|
|
254
|
+
const config = m('Load config', () => loadConfig('./project.toml'));
|
|
255
|
+
if (!config) process.exit(1);
|
|
256
|
+
|
|
257
|
+
m('Compile', () => compile(config));
|
|
258
|
+
m('Bundle', () => bundle(config));
|
|
259
|
+
m('Write output', () => writeOutput(config.outDir));
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Types
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
export type MeasureFn = {
|
|
267
|
+
<U>(label: string | object, fn: () => Promise<U>): Promise<U | null>;
|
|
268
|
+
<U>(label: string | object, fn: (m: MeasureFn) => Promise<U>): Promise<U | null>;
|
|
269
|
+
(label: string | object): Promise<null>;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
export type MeasureSyncFn = {
|
|
273
|
+
<U>(label: string | object, fn: () => U): U | null;
|
|
274
|
+
<U>(label: string | object, fn: (m: MeasureSyncFn) => U): U | null;
|
|
275
|
+
(label: string | object): null;
|
|
276
|
+
};
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## Zero Dependencies
|
|
280
|
+
|
|
281
|
+
`measure-fn` has **zero runtime dependencies**. It uses only `performance.now()` and `console.log`/`console.error`. It works in Bun, Node, Deno, or any JavaScript runtime.
|
|
282
|
+
|
|
283
|
+
## License
|
|
284
|
+
|
|
285
|
+
MIT
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: measure-fn
|
|
3
|
+
description: Zero-dependency function performance measurement with hierarchical logging. Use this to structure ALL programs as measured, observable pipelines.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# measure-fn — Structured Program Measurement
|
|
7
|
+
|
|
8
|
+
**Every program you write should be observable from day one.** `measure-fn` wraps any function — async or sync — with automatic performance timing, hierarchical structured logging, and graceful error isolation. No SDK, no config, no dashboards — just wrap your functions.
|
|
9
|
+
|
|
10
|
+
## The Rule: If It Does Work, Measure It
|
|
11
|
+
|
|
12
|
+
When building any program — a server, a CLI tool, a background job, a pipeline — **wrap every meaningful operation with `measure`**. This is not optional overhead; it's the difference between debugging blind and having a complete execution trace.
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
import { measure } from 'measure-fn';
|
|
16
|
+
|
|
17
|
+
// ❌ Don't write blind functions
|
|
18
|
+
async function handleRequest(req) {
|
|
19
|
+
const user = await getUser(req.userId);
|
|
20
|
+
const data = await fetchData(user);
|
|
21
|
+
return transform(data);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ✅ Make every step visible
|
|
25
|
+
async function handleRequest(req) {
|
|
26
|
+
return await measure({ label: 'Handle Request', userId: req.userId }, async (m) => {
|
|
27
|
+
const user = await m('Get user', () => getUser(req.userId));
|
|
28
|
+
if (!user) return null; // error already logged
|
|
29
|
+
const data = await m('Fetch data', () => fetchData(user));
|
|
30
|
+
if (!data) return null;
|
|
31
|
+
return m('Transform', () => transform(data));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
bun add measure-fn
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Core Patterns
|
|
43
|
+
|
|
44
|
+
### 1. Label First, Function Second
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
// Reads like a sentence: "measure 'Fetch users' by running this function"
|
|
48
|
+
const users = await measure('Fetch users', () => fetchUsers());
|
|
49
|
+
|
|
50
|
+
// Sync equivalent
|
|
51
|
+
const config = measureSync('Load config', () => loadConfig());
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 2. Nested Measurement Trees
|
|
55
|
+
|
|
56
|
+
The callback receives a nested `m` for composing sub-operations:
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
await measure('Build Dashboard', async (m) => {
|
|
60
|
+
const data = await m('Fetch', () => fetchData());
|
|
61
|
+
await m('Process', async (m2) => {
|
|
62
|
+
await m2('Step A', () => stepA(data));
|
|
63
|
+
await m2('Step B', () => stepB(data));
|
|
64
|
+
});
|
|
65
|
+
await m('Render', () => render(data));
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Output:
|
|
70
|
+
```
|
|
71
|
+
> [a] Build Dashboard
|
|
72
|
+
> [a-a] Fetch
|
|
73
|
+
< [a-a] ✓ 120.00ms
|
|
74
|
+
> [a-b] Process
|
|
75
|
+
> [a-b-a] Step A
|
|
76
|
+
< [a-b-a] ✓ 15.00ms
|
|
77
|
+
> [a-b-b] Step B
|
|
78
|
+
< [a-b-b] ✓ 22.00ms
|
|
79
|
+
< [a-b] ✓ 38.00ms
|
|
80
|
+
> [a-c] Render
|
|
81
|
+
< [a-c] ✓ 5.00ms
|
|
82
|
+
< [a] ✓ 165.00ms
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### 3. Object Labels with Metadata
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
await measure({ label: 'Fetch User', userId: 5, region: 'us-east' }, async () => fetchUser(5));
|
|
89
|
+
// Logs: > [a] Fetch User (userId=5 region="us-east")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### 4. Error Isolation — Errors Are Data, Not Crashes
|
|
93
|
+
|
|
94
|
+
**measure never throws.** On error: logs it, returns `null`. Your program keeps running.
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
await measure('Pipeline', async (m) => {
|
|
98
|
+
const result = await m('Risky step', () => riskyOperation());
|
|
99
|
+
if (result === null) {
|
|
100
|
+
// Error was already logged with ID, stack trace, and cause
|
|
101
|
+
// Decide: skip, fallback, or abort
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await m('Next step', () => useResult(result));
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### 5. Annotations (Label-Only)
|
|
109
|
+
|
|
110
|
+
Log a marker without wrapping a function:
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
await measure('Checkpoint: all users processed');
|
|
114
|
+
measureSync({ label: 'Config loaded', env: 'production' });
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## When to Use
|
|
118
|
+
|
|
119
|
+
- **Always.** Every server handler, every background job, every CLI command, every pipeline.
|
|
120
|
+
- **API handlers** — wrap the full request lifecycle
|
|
121
|
+
- **Database operations** — time queries, transactions, migrations
|
|
122
|
+
- **External calls** — HTTP requests, file I/O, SDK calls
|
|
123
|
+
- **Batch processing** — measure each item in a loop
|
|
124
|
+
- **CLI tools** — structure the entire command as a measured tree
|
|
125
|
+
|
|
126
|
+
## Testing
|
|
127
|
+
|
|
128
|
+
Use `resetCounter()` for deterministic IDs:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
import { resetCounter } from 'measure-fn';
|
|
132
|
+
beforeEach(() => resetCounter());
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## API Reference
|
|
136
|
+
|
|
137
|
+
| Export | Signature | Description |
|
|
138
|
+
|--------|-----------|-------------|
|
|
139
|
+
| `measure` | `(label, fn?) => Promise<T \| null>` | Async measurement |
|
|
140
|
+
| `measureSync` | `(label, fn?) => T \| null` | Sync measurement |
|
|
141
|
+
| `resetCounter` | `() => void` | Reset global ID counter |
|
|
142
|
+
| `MeasureFn` | type | Nested async measure function type |
|
|
143
|
+
| `MeasureSyncFn` | type | Nested sync measure function type |
|
|
144
|
+
|
|
145
|
+
## Output Symbols
|
|
146
|
+
|
|
147
|
+
| Symbol | Meaning |
|
|
148
|
+
|--------|---------|
|
|
149
|
+
| `>` | Start |
|
|
150
|
+
| `<` | Complete |
|
|
151
|
+
| `=` | Annotation |
|
|
152
|
+
| `✓` | Success |
|
|
153
|
+
| `✗` | Error |
|
|
154
|
+
|
|
155
|
+
IDs are alphabetic: `[a]`, `[a-a]`, `[a-b-c]`. Every ID uniquely addresses one operation — grep for it to find exactly what happened.
|
package/bun.lock
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
}
|
package/example.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { measure, measureSync, type MeasureFn } from "./index.ts";
|
|
2
|
+
|
|
3
|
+
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
4
|
+
|
|
5
|
+
async function fetchUser(userId: number) {
|
|
6
|
+
await sleep(100);
|
|
7
|
+
if (userId === 999) throw new Error('User not found', { cause: { userId } });
|
|
8
|
+
return { id: userId, name: `User ${userId}` };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function fetchPosts(userId: number) {
|
|
12
|
+
await sleep(150);
|
|
13
|
+
return [{ id: 1, title: 'First Post', userId }];
|
|
14
|
+
}
|
|
15
|
+
async function fetchComments(postId: number) {
|
|
16
|
+
await sleep(80);
|
|
17
|
+
return [{ id: 1, text: 'Great post!', postId }];
|
|
18
|
+
}
|
|
19
|
+
function syncFetch() {
|
|
20
|
+
do {
|
|
21
|
+
} while (Math.random() < 0.99999999);
|
|
22
|
+
return 42;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function comprehensiveWorkflow() {
|
|
26
|
+
// Sync: label first, fn second
|
|
27
|
+
const syncValue = measureSync('get sync value', syncFetch);
|
|
28
|
+
|
|
29
|
+
// Annotation-only call (label only, no fn)
|
|
30
|
+
if (syncValue !== null) {
|
|
31
|
+
measureSync(`sync returned ${syncValue}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Async: label first, fn second
|
|
35
|
+
await measure('Comprehensive Workflow Example', async (m) => {
|
|
36
|
+
const syncValue = await m('get sync value', syncFetch);
|
|
37
|
+
await m({ label: 'noop measure object', values: [syncValue] });
|
|
38
|
+
|
|
39
|
+
const user1 = await m(
|
|
40
|
+
{ label: 'Fetch User', userId: 1 },
|
|
41
|
+
() => fetchUser(1)
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// @note measure never throws, only returns null in case of exception
|
|
45
|
+
await m(
|
|
46
|
+
{ label: 'Fetch Invalid User', userId: 999 },
|
|
47
|
+
() => fetchUser(999)
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
await m('Fetch Multiple Users in Parallel', async (m2: MeasureFn) => {
|
|
51
|
+
const userPromises = [2, 3, 4].map(id =>
|
|
52
|
+
m2({ label: 'Fetch User', userId: id }, () => fetchUser(id))
|
|
53
|
+
);
|
|
54
|
+
await Promise.all(userPromises);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (user1 === null) return;
|
|
58
|
+
|
|
59
|
+
await m('Enrich Posts with Comments', async (m2: MeasureFn) => {
|
|
60
|
+
const posts = await m2({ label: 'Fetch Posts', userId: user1.id }, () => fetchPosts(user1.id));
|
|
61
|
+
|
|
62
|
+
if (posts === null) return;
|
|
63
|
+
for (const post of posts) {
|
|
64
|
+
await m2({ label: 'Fetch Comments', postId: post.id }, () => fetchComments(post.id));
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// To run it:
|
|
71
|
+
comprehensiveWorkflow().then(() => {
|
|
72
|
+
console.log('\n✅ Workflow complete.');
|
|
73
|
+
});
|
package/index.test.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, spyOn } from "bun:test";
|
|
2
|
+
import { measure, measureSync, resetCounter } from "./index.ts";
|
|
3
|
+
|
|
4
|
+
// Capture console output for assertions
|
|
5
|
+
function captureConsole() {
|
|
6
|
+
const logs: string[] = [];
|
|
7
|
+
const errors: string[] = [];
|
|
8
|
+
|
|
9
|
+
const logSpy = spyOn(console, "log").mockImplementation((...args: any[]) => {
|
|
10
|
+
logs.push(args.map(String).join(" "));
|
|
11
|
+
});
|
|
12
|
+
const errorSpy = spyOn(console, "error").mockImplementation((...args: any[]) => {
|
|
13
|
+
errors.push(args.map(String).join(" "));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
logs,
|
|
18
|
+
errors,
|
|
19
|
+
restore: () => {
|
|
20
|
+
logSpy.mockRestore();
|
|
21
|
+
errorSpy.mockRestore();
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
27
|
+
|
|
28
|
+
// ─── measure (async) ─────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
describe("measure (async)", () => {
|
|
31
|
+
beforeEach(() => resetCounter());
|
|
32
|
+
|
|
33
|
+
test("runs a function and returns its result", async () => {
|
|
34
|
+
const out = captureConsole();
|
|
35
|
+
const result = await measure("my op", async () => 42);
|
|
36
|
+
out.restore();
|
|
37
|
+
|
|
38
|
+
expect(result).toBe(42);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("logs start and success", async () => {
|
|
42
|
+
const out = captureConsole();
|
|
43
|
+
await measure("fetch data", async () => "ok");
|
|
44
|
+
out.restore();
|
|
45
|
+
|
|
46
|
+
expect(out.logs[0]).toStartWith("> [a] fetch data");
|
|
47
|
+
expect(out.logs[1]).toMatch(/< \[a\] ✓ \d+\.\d+ms/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("returns null and logs error on throw", async () => {
|
|
51
|
+
const out = captureConsole();
|
|
52
|
+
const result = await measure("will fail", async () => {
|
|
53
|
+
throw new Error("boom");
|
|
54
|
+
});
|
|
55
|
+
out.restore();
|
|
56
|
+
|
|
57
|
+
expect(result).toBeNull();
|
|
58
|
+
expect(out.logs[1]).toMatch(/< \[a\] ✗ \d+\.\d+ms \(boom\)/);
|
|
59
|
+
expect(out.errors.length).toBeGreaterThan(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("logs error cause when present", async () => {
|
|
63
|
+
const out = captureConsole();
|
|
64
|
+
await measure("caused error", async () => {
|
|
65
|
+
throw new Error("fail", { cause: { reason: "test" } });
|
|
66
|
+
});
|
|
67
|
+
out.restore();
|
|
68
|
+
|
|
69
|
+
const causeLog = out.errors.find((e) => e.includes("Cause:"));
|
|
70
|
+
expect(causeLog).toBeDefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("label-only call (no function) logs start and returns null", async () => {
|
|
74
|
+
const out = captureConsole();
|
|
75
|
+
const result = await measure("annotation only");
|
|
76
|
+
out.restore();
|
|
77
|
+
|
|
78
|
+
expect(result).toBeNull();
|
|
79
|
+
expect(out.logs[0]).toStartWith("> [a] annotation only");
|
|
80
|
+
expect(out.logs.length).toBe(1); // no completion log
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("object label extracts .label and logs extra params", async () => {
|
|
84
|
+
const out = captureConsole();
|
|
85
|
+
await measure({ label: "Fetch User", userId: 5 }, async () => "ok");
|
|
86
|
+
out.restore();
|
|
87
|
+
|
|
88
|
+
expect(out.logs[0]).toContain("Fetch User");
|
|
89
|
+
expect(out.logs[0]).toContain("userId=5");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("nested measure produces hierarchical IDs", async () => {
|
|
93
|
+
const out = captureConsole();
|
|
94
|
+
await measure("parent", async (m) => {
|
|
95
|
+
await m("child A", async () => 1);
|
|
96
|
+
await m("child B", async () => 2);
|
|
97
|
+
});
|
|
98
|
+
out.restore();
|
|
99
|
+
|
|
100
|
+
expect(out.logs[0]).toStartWith("> [a] parent");
|
|
101
|
+
expect(out.logs[1]).toStartWith("> [a-a] child A");
|
|
102
|
+
expect(out.logs[3]).toStartWith("> [a-b] child B");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("deeply nested measure produces correct IDs", async () => {
|
|
106
|
+
const out = captureConsole();
|
|
107
|
+
await measure("root", async (m) => {
|
|
108
|
+
await m("level 1", async (m2) => {
|
|
109
|
+
await m2("level 2", async () => "deep");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
out.restore();
|
|
113
|
+
|
|
114
|
+
expect(out.logs[0]).toStartWith("> [a] root");
|
|
115
|
+
expect(out.logs[1]).toStartWith("> [a-a] level 1");
|
|
116
|
+
expect(out.logs[2]).toStartWith("> [a-a-a] level 2");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("multiple top-level calls get sequential IDs", async () => {
|
|
120
|
+
const out = captureConsole();
|
|
121
|
+
await measure("first", async () => 1);
|
|
122
|
+
await measure("second", async () => 2);
|
|
123
|
+
out.restore();
|
|
124
|
+
|
|
125
|
+
expect(out.logs[0]).toStartWith("> [a] first");
|
|
126
|
+
expect(out.logs[2]).toStartWith("> [b] second");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("nested annotation (label only) inside measure", async () => {
|
|
130
|
+
const out = captureConsole();
|
|
131
|
+
await measure("parent", async (m) => {
|
|
132
|
+
await m("just a note");
|
|
133
|
+
await m("do work", async () => 42);
|
|
134
|
+
});
|
|
135
|
+
out.restore();
|
|
136
|
+
|
|
137
|
+
// Annotation uses '=' prefix
|
|
138
|
+
expect(out.logs[1]).toStartWith("= [a] just a note");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("child error does not crash parent", async () => {
|
|
142
|
+
const out = captureConsole();
|
|
143
|
+
const result = await measure("parent", async (m) => {
|
|
144
|
+
const childResult = await m("failing child", async () => {
|
|
145
|
+
throw new Error("child error");
|
|
146
|
+
});
|
|
147
|
+
expect(childResult).toBeNull();
|
|
148
|
+
return "parent ok";
|
|
149
|
+
});
|
|
150
|
+
out.restore();
|
|
151
|
+
|
|
152
|
+
expect(result).toBe("parent ok");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("non-Error throw is handled gracefully", async () => {
|
|
156
|
+
const out = captureConsole();
|
|
157
|
+
const result = await measure("string throw", async () => {
|
|
158
|
+
throw "raw string error";
|
|
159
|
+
});
|
|
160
|
+
out.restore();
|
|
161
|
+
|
|
162
|
+
expect(result).toBeNull();
|
|
163
|
+
expect(out.logs[1]).toContain("raw string error");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ─── measureSync ─────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
describe("measureSync", () => {
|
|
170
|
+
beforeEach(() => resetCounter());
|
|
171
|
+
|
|
172
|
+
test("runs a function and returns its result", () => {
|
|
173
|
+
const out = captureConsole();
|
|
174
|
+
const result = measureSync("compute", () => 100);
|
|
175
|
+
out.restore();
|
|
176
|
+
|
|
177
|
+
expect(result).toBe(100);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("logs start and success", () => {
|
|
181
|
+
const out = captureConsole();
|
|
182
|
+
measureSync("parse json", () => JSON.parse('{"a":1}'));
|
|
183
|
+
out.restore();
|
|
184
|
+
|
|
185
|
+
expect(out.logs[0]).toStartWith("> [a] parse json");
|
|
186
|
+
expect(out.logs[1]).toMatch(/< \[a\] ✓ \d+\.\d+ms/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("returns null and logs error on throw", () => {
|
|
190
|
+
const out = captureConsole();
|
|
191
|
+
const result = measureSync("will fail", () => {
|
|
192
|
+
throw new Error("sync boom");
|
|
193
|
+
});
|
|
194
|
+
out.restore();
|
|
195
|
+
|
|
196
|
+
expect(result).toBeNull();
|
|
197
|
+
expect(out.logs[1]).toMatch(/✗/);
|
|
198
|
+
expect(out.logs[1]).toContain("sync boom");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("label-only call returns null", () => {
|
|
202
|
+
const out = captureConsole();
|
|
203
|
+
const result = measureSync("just a note");
|
|
204
|
+
out.restore();
|
|
205
|
+
|
|
206
|
+
expect(result).toBeNull();
|
|
207
|
+
expect(out.logs[0]).toStartWith("> [a] just a note");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("nested measureSync produces hierarchical IDs", () => {
|
|
211
|
+
const out = captureConsole();
|
|
212
|
+
measureSync("parent", (m) => {
|
|
213
|
+
m("child X", () => 10);
|
|
214
|
+
m("child Y", () => 20);
|
|
215
|
+
return 30;
|
|
216
|
+
});
|
|
217
|
+
out.restore();
|
|
218
|
+
|
|
219
|
+
expect(out.logs[0]).toStartWith("> [a] parent");
|
|
220
|
+
expect(out.logs[1]).toStartWith("> [a-a] child X");
|
|
221
|
+
expect(out.logs[3]).toStartWith("> [a-b] child Y");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("child error does not crash parent (sync)", () => {
|
|
225
|
+
const out = captureConsole();
|
|
226
|
+
const result = measureSync("parent", (m) => {
|
|
227
|
+
const bad = m("fail", () => {
|
|
228
|
+
throw new Error("nope");
|
|
229
|
+
});
|
|
230
|
+
expect(bad).toBeNull();
|
|
231
|
+
return "still ok";
|
|
232
|
+
});
|
|
233
|
+
out.restore();
|
|
234
|
+
|
|
235
|
+
expect(result).toBe("still ok");
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ─── toAlpha / ID generation ─────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
describe("ID generation", () => {
|
|
242
|
+
beforeEach(() => resetCounter());
|
|
243
|
+
|
|
244
|
+
test("generates sequential alpha IDs for many calls", async () => {
|
|
245
|
+
const out = captureConsole();
|
|
246
|
+
for (let i = 0; i < 28; i++) {
|
|
247
|
+
await measure(`op-${i}`, async () => null);
|
|
248
|
+
}
|
|
249
|
+
out.restore();
|
|
250
|
+
|
|
251
|
+
// First 26: a-z, then 27th = aa, 28th = ab
|
|
252
|
+
expect(out.logs[0]).toStartWith("> [a]");
|
|
253
|
+
expect(out.logs[50]).toStartWith("> [z]"); // 26th call (index 25) => lines 50,51
|
|
254
|
+
expect(out.logs[52]).toStartWith("> [aa]"); // 27th
|
|
255
|
+
expect(out.logs[54]).toStartWith("> [ab]"); // 28th
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ─── resetCounter ────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
describe("resetCounter", () => {
|
|
262
|
+
test("resets global ID counter", async () => {
|
|
263
|
+
resetCounter();
|
|
264
|
+
|
|
265
|
+
const out1 = captureConsole();
|
|
266
|
+
await measure("first run", async () => null);
|
|
267
|
+
out1.restore();
|
|
268
|
+
expect(out1.logs[0]).toStartWith("> [a]");
|
|
269
|
+
|
|
270
|
+
resetCounter();
|
|
271
|
+
|
|
272
|
+
const out2 = captureConsole();
|
|
273
|
+
await measure("second run", async () => null);
|
|
274
|
+
out2.restore();
|
|
275
|
+
expect(out2.logs[0]).toStartWith("> [a]"); // back to [a]
|
|
276
|
+
});
|
|
277
|
+
});
|
package/index.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
const toAlpha = (num: number): string => {
|
|
2
|
+
let result = '';
|
|
3
|
+
let n = num;
|
|
4
|
+
do {
|
|
5
|
+
result = String.fromCharCode(97 + (n % 26)) + result;
|
|
6
|
+
n = Math.floor(n / 26) - 1;
|
|
7
|
+
} while (n >= 0);
|
|
8
|
+
return result;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// Shared helpers
|
|
12
|
+
const buildActionLabel = (actionInternal: string | object): string => {
|
|
13
|
+
return typeof actionInternal === 'object' && actionInternal !== null && 'label' in actionInternal
|
|
14
|
+
? String(actionInternal.label)
|
|
15
|
+
: String(actionInternal);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const buildLogMessage = (
|
|
19
|
+
prefix: string,
|
|
20
|
+
actionLabel: string,
|
|
21
|
+
actionInternal: string | object,
|
|
22
|
+
fullIdChainStr: string
|
|
23
|
+
): string => {
|
|
24
|
+
let logMessage = `${prefix} ${fullIdChainStr} ${actionLabel}`;
|
|
25
|
+
if (typeof actionInternal === 'object' && actionInternal !== null) {
|
|
26
|
+
const details = { ...actionInternal };
|
|
27
|
+
if ('label' in details) delete details.label;
|
|
28
|
+
if (Object.keys(details).length > 0) {
|
|
29
|
+
const params = Object.entries(details)
|
|
30
|
+
.map(([key, value]) => `${key}=${JSON.stringify(value)}`)
|
|
31
|
+
.join(' ');
|
|
32
|
+
logMessage += ` (${params})`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return logMessage;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const logStart = (fullIdChainStr: string, actionInternal: string | object) => {
|
|
39
|
+
const actionLabel = buildActionLabel(actionInternal);
|
|
40
|
+
const logMessage = buildLogMessage('>', actionLabel, actionInternal, fullIdChainStr);
|
|
41
|
+
console.log(logMessage);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const logNested = (fullIdChainStr: string, actionInternalNested: string | object) => {
|
|
45
|
+
if (!actionInternalNested) return;
|
|
46
|
+
const actionLabelNested = buildActionLabel(actionInternalNested);
|
|
47
|
+
const logMessageNested = buildLogMessage('=', actionLabelNested, actionInternalNested, fullIdChainStr);
|
|
48
|
+
console.log(logMessageNested);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const logSuccess = (fullIdChainStr: string, duration: number) => {
|
|
52
|
+
console.log(`< ${fullIdChainStr} ✓ ${duration.toFixed(2)}ms`);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const logError = (fullIdChainStr: string, duration: number, error: unknown) => {
|
|
56
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
57
|
+
console.log(`< ${fullIdChainStr} ✗ ${duration.toFixed(2)}ms (${errorMsg})`);
|
|
58
|
+
if (error instanceof Error) {
|
|
59
|
+
console.error(`${fullIdChainStr}`, error.stack ?? error.message);
|
|
60
|
+
if (error.cause) {
|
|
61
|
+
console.error(`${fullIdChainStr} Cause:`, error.cause);
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
console.error(`${fullIdChainStr}`, error);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Nested measure function — label first, fn second (or just a label for annotation) */
|
|
69
|
+
export type MeasureFn = {
|
|
70
|
+
<U>(label: string | object, fn: () => Promise<U>): Promise<U | null>;
|
|
71
|
+
<U>(label: string | object, fn: (m: MeasureFn) => Promise<U>): Promise<U | null>;
|
|
72
|
+
(label: string | object): Promise<null>;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Nested measureSync function — label first, fn second (or just a label for annotation) */
|
|
76
|
+
export type MeasureSyncFn = {
|
|
77
|
+
<U>(label: string | object, fn: () => U): U | null;
|
|
78
|
+
<U>(label: string | object, fn: (m: MeasureSyncFn) => U): U | null;
|
|
79
|
+
(label: string | object): null;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const createNestedResolver = (
|
|
83
|
+
isAsync: boolean,
|
|
84
|
+
fullIdChain: string[],
|
|
85
|
+
childCounterRef: { value: number },
|
|
86
|
+
resolver: <U>(fn: any, action: any, chain: (string | number)[]) => Promise<U | null> | (U | null)
|
|
87
|
+
) => {
|
|
88
|
+
return (...args: any[]) => {
|
|
89
|
+
// New order: (label, fn?) — label is always first
|
|
90
|
+
const label = args[0];
|
|
91
|
+
const fn = args[1];
|
|
92
|
+
|
|
93
|
+
if (typeof fn === 'function') {
|
|
94
|
+
const childParentChain = [...fullIdChain, childCounterRef.value++];
|
|
95
|
+
return resolver(fn, label, childParentChain);
|
|
96
|
+
} else {
|
|
97
|
+
logNested(`[${fullIdChain.join('-')}]`, label);
|
|
98
|
+
return isAsync ? Promise.resolve(null) : null;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
let globalRootCounter = 0;
|
|
104
|
+
|
|
105
|
+
/** Reset the global counter — useful for deterministic test output */
|
|
106
|
+
export const resetCounter = () => {
|
|
107
|
+
globalRootCounter = 0;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Measure an async operation with hierarchical logging.
|
|
112
|
+
*
|
|
113
|
+
* @param label - A string or object describing the operation
|
|
114
|
+
* @param fn - The async function to measure (receives nested `measure` as argument)
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* ```ts
|
|
118
|
+
* await measure('Fetch users', async (m) => {
|
|
119
|
+
* const user = await m('Get user 1', () => fetchUser(1));
|
|
120
|
+
* await m('Get posts', () => fetchPosts(user.id));
|
|
121
|
+
* });
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
export const measure = async <T = null>(
|
|
125
|
+
arg1: string | object,
|
|
126
|
+
arg2?: ((measure: MeasureFn) => Promise<T>)
|
|
127
|
+
): Promise<T | null> => {
|
|
128
|
+
const _measureInternal = async <U>(
|
|
129
|
+
fnInternal: (measure: MeasureFn) => Promise<U>,
|
|
130
|
+
actionInternal: string | object,
|
|
131
|
+
parentIdChain: (string | number)[]
|
|
132
|
+
): Promise<U | null> => {
|
|
133
|
+
const start = performance.now();
|
|
134
|
+
const childCounterRef = { value: 0 };
|
|
135
|
+
|
|
136
|
+
const currentId = toAlpha(parentIdChain.pop() ?? 0);
|
|
137
|
+
const fullIdChain = [...parentIdChain, currentId];
|
|
138
|
+
const fullIdChainStr = `[${fullIdChain.join('-')}]`;
|
|
139
|
+
|
|
140
|
+
logStart(fullIdChainStr, actionInternal);
|
|
141
|
+
|
|
142
|
+
const measureForNextLevel = createNestedResolver(true, fullIdChain, childCounterRef, _measureInternal);
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const result = await fnInternal(measureForNextLevel as MeasureFn);
|
|
146
|
+
const duration = performance.now() - start;
|
|
147
|
+
logSuccess(fullIdChainStr, duration);
|
|
148
|
+
return result;
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const duration = performance.now() - start;
|
|
151
|
+
logError(fullIdChainStr, duration, error);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
if (typeof arg2 === 'function') {
|
|
157
|
+
return _measureInternal(arg2, arg1, [globalRootCounter++]) as Promise<T | null>;
|
|
158
|
+
} else {
|
|
159
|
+
const currentId = toAlpha(globalRootCounter++);
|
|
160
|
+
const fullIdChainStr = `[${currentId}]`;
|
|
161
|
+
logStart(fullIdChainStr, arg1);
|
|
162
|
+
return Promise.resolve(null);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Measure a synchronous operation with hierarchical logging.
|
|
168
|
+
*
|
|
169
|
+
* @param label - A string or object describing the operation
|
|
170
|
+
* @param fn - The sync function to measure (receives nested `measureSync` as argument)
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* ```ts
|
|
174
|
+
* const result = measureSync('Parse config', () => {
|
|
175
|
+
* return JSON.parse(configStr);
|
|
176
|
+
* });
|
|
177
|
+
* ```
|
|
178
|
+
*/
|
|
179
|
+
export const measureSync = <T = null>(
|
|
180
|
+
arg1: string | object,
|
|
181
|
+
arg2?: ((measure: MeasureSyncFn) => T)
|
|
182
|
+
): T | null => {
|
|
183
|
+
const _measureInternalSync = <U>(
|
|
184
|
+
fnInternal: (measure: MeasureSyncFn) => U,
|
|
185
|
+
actionInternal: string | object,
|
|
186
|
+
parentIdChain: (string | number)[]
|
|
187
|
+
): U | null => {
|
|
188
|
+
const start = performance.now();
|
|
189
|
+
const childCounterRef = { value: 0 };
|
|
190
|
+
|
|
191
|
+
const currentId = toAlpha(parentIdChain.pop() ?? 0);
|
|
192
|
+
const fullIdChain = [...parentIdChain, currentId];
|
|
193
|
+
const fullIdChainStr = `[${fullIdChain.join('-')}]`;
|
|
194
|
+
|
|
195
|
+
logStart(fullIdChainStr, actionInternal);
|
|
196
|
+
|
|
197
|
+
const measureForNextLevel = createNestedResolver(false, fullIdChain, childCounterRef, _measureInternalSync);
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const result = fnInternal(measureForNextLevel as MeasureSyncFn);
|
|
201
|
+
const duration = performance.now() - start;
|
|
202
|
+
logSuccess(fullIdChainStr, duration);
|
|
203
|
+
return result;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
const duration = performance.now() - start;
|
|
206
|
+
logError(fullIdChainStr, duration, error);
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
if (typeof arg2 === 'function') {
|
|
212
|
+
return _measureInternalSync(arg2, arg1, [globalRootCounter++]) as T | null;
|
|
213
|
+
} else {
|
|
214
|
+
const currentId = toAlpha(globalRootCounter++);
|
|
215
|
+
const fullIdChainStr = `[${currentId}]`;
|
|
216
|
+
logStart(fullIdChainStr, arg1);
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "measure-fn",
|
|
3
|
+
"module": "index.ts",
|
|
4
|
+
"main": "./index.ts",
|
|
5
|
+
"version": "2.0.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"private": false,
|
|
8
|
+
"description": "Zero-dependency function performance measurement with hierarchical logging",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"measure",
|
|
11
|
+
"performance",
|
|
12
|
+
"timing",
|
|
13
|
+
"logging",
|
|
14
|
+
"tracing",
|
|
15
|
+
"hierarchical"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/7flash/measure-fn"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/bun": "latest"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"typescript": "^5"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "bun test",
|
|
30
|
+
"example": "bun run example.ts"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
// Environment setup & latest features
|
|
4
|
+
"lib": ["ESNext"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
|
|
11
|
+
// Bundler mode
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"verbatimModuleSyntax": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
// Best practices
|
|
18
|
+
"strict": true,
|
|
19
|
+
"skipLibCheck": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUncheckedIndexedAccess": true,
|
|
22
|
+
|
|
23
|
+
// Some stricter flags (disabled by default)
|
|
24
|
+
"noUnusedLocals": false,
|
|
25
|
+
"noUnusedParameters": false,
|
|
26
|
+
"noPropertyAccessFromIndexSignature": false
|
|
27
|
+
}
|
|
28
|
+
}
|