loggily 0.3.0 → 0.4.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 +67 -22
- package/package.json +24 -11
- package/src/context.ts +26 -11
- package/src/core.ts +118 -72
- package/src/file-writer.ts +12 -6
- package/src/index.browser.ts +9 -1
- package/src/index.ts +9 -1
- package/src/tracing.ts +11 -3
- package/src/worker.ts +119 -132
- package/.github/workflows/docs.yml +0 -58
- package/.github/workflows/release.yml +0 -31
- package/.github/workflows/test.yml +0 -20
- package/CHANGELOG.md +0 -45
- package/CLAUDE.md +0 -299
- package/CONTRIBUTING.md +0 -58
- package/benchmarks/overhead.ts +0 -267
- package/bun.lock +0 -479
- package/docs/api-reference.md +0 -400
- package/docs/benchmarks.md +0 -106
- package/docs/comparison.md +0 -315
- package/docs/conditional-logging-research.md +0 -159
- package/docs/guide.md +0 -205
- package/docs/migration-from-debug.md +0 -310
- package/docs/migration-from-pino.md +0 -178
- package/docs/migration-from-winston.md +0 -179
- package/docs/site/.vitepress/config.ts +0 -67
- package/docs/site/api/configuration.md +0 -94
- package/docs/site/api/index.md +0 -61
- package/docs/site/api/logger.md +0 -99
- package/docs/site/api/worker.md +0 -120
- package/docs/site/api/writers.md +0 -69
- package/docs/site/guide/getting-started.md +0 -143
- package/docs/site/guide/journey.md +0 -203
- package/docs/site/guide/migration-from-debug.md +0 -24
- package/docs/site/guide/spans.md +0 -139
- package/docs/site/guide/why.md +0 -55
- package/docs/site/guide/workers.md +0 -113
- package/docs/site/guide/zero-overhead.md +0 -87
- package/docs/site/index.md +0 -54
- package/tests/features.test.ts +0 -552
- package/tests/logger.test.ts +0 -944
- package/tests/tracing.test.ts +0 -618
- package/tests/universal.test.ts +0 -107
- package/tests/worker.test.ts +0 -590
- package/tsconfig.json +0 -20
- package/vitest.config.ts +0 -10
package/README.md
CHANGED
|
@@ -1,25 +1,29 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Loggily
|
|
2
2
|
|
|
3
3
|
**Clarity without the clutter.**
|
|
4
4
|
|
|
5
|
+
One library. One namespace tree. One output pipeline. For logs (structured JSON or console), debug(), and tracing spans. Near-zero overhead from disabled log levels. Pure TypeScript. ~3KB. Zero dependencies.
|
|
6
|
+
|
|
5
7
|
[](https://github.com/beorn/loggily/actions/workflows/test.yml)
|
|
6
|
-
[](https://www.typescriptlang.org/)
|
|
7
9
|
[](LICENSE)
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
Most projects wire together three separate tools that don't talk to each other: **debug** for conditional output, **pino/winston** for production logs, **OpenTelemetry** for tracing. loggily integrates all three into one unified system — same namespace tree, same output pipeline, same `?.` zero-overhead pattern. Every logger is a potential span: call `.span()` and it becomes one, with automatic timing, parent-child tracking, and trace IDs. Nothing to sync, nothing to configure separately.
|
|
12
|
-
|
|
13
|
-
In development, you get colorized console output with timestamps, level colors, and clickable source lines — loggily uses native `console` methods so stack traces stay intact in DevTools. In production, the same code emits structured JSON. No config change needed.
|
|
14
|
-
|
|
15
|
-
Read **[The Journey](docs/guide.md)** for the full story.
|
|
11
|
+
> Early release (0.x) -- API may evolve before 1.0.
|
|
16
12
|
|
|
17
13
|
## Install
|
|
18
14
|
|
|
19
15
|
```bash
|
|
20
|
-
|
|
16
|
+
npm install loggily
|
|
21
17
|
```
|
|
22
18
|
|
|
19
|
+
| Requirement | Version |
|
|
20
|
+
| ------------- | ------------------------------------------------- |
|
|
21
|
+
| Node.js | 18+ |
|
|
22
|
+
| Bun | 1.0+ |
|
|
23
|
+
| TypeScript | 5.2+ (for `using`; `.end()` works on any version) |
|
|
24
|
+
| Module format | ESM-only |
|
|
25
|
+
| Browser | Supported via conditional export |
|
|
26
|
+
|
|
23
27
|
## Quick Start
|
|
24
28
|
|
|
25
29
|
```typescript
|
|
@@ -27,55 +31,96 @@ import { createLogger } from "loggily"
|
|
|
27
31
|
|
|
28
32
|
const log = createLogger("myapp")
|
|
29
33
|
|
|
30
|
-
// ?. skips the entire call — including argument evaluation — when the level is disabled
|
|
34
|
+
// ?. skips the entire call — including argument evaluation — when the level is disabled
|
|
31
35
|
log.info?.("server started", { port: 3000 })
|
|
32
36
|
log.debug?.("cache hit", { key: "user:42" })
|
|
33
37
|
log.error?.(new Error("connection lost"))
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Output in development (colorized with timestamps and clickable source lines):
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
14:32:15 INFO myapp server started {port: 3000}
|
|
44
|
+
14:32:15 DEBUG myapp cache hit {key: "user:42"}
|
|
45
|
+
14:32:15 ERROR myapp connection lost
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Set `NODE_ENV=production` or `LOG_FORMAT=json` and the same code emits structured JSON:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{ "time": "2024-01-15T14:32:15.123Z", "level": "info", "name": "myapp", "msg": "server started", "port": 3000 }
|
|
52
|
+
```
|
|
34
53
|
|
|
35
|
-
|
|
54
|
+
### Spans
|
|
55
|
+
|
|
56
|
+
Time operations with lightweight spans. Uses TC39 [Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management) (`using` requires TypeScript 5.2+ and runtime support). For other environments, call `.end()` manually:
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
// With `using` (TS 5.2+, Bun 1.0+, Node 22+)
|
|
36
60
|
{
|
|
37
61
|
using span = log.span("db:query", { table: "users" })
|
|
38
62
|
const users = await db.query("SELECT * FROM users")
|
|
39
63
|
span.spanData.count = users.length
|
|
40
64
|
}
|
|
41
65
|
// Output: SPAN myapp:db:query (45ms) {count: 100, table: "users"}
|
|
66
|
+
|
|
67
|
+
// Without `using` — works on any runtime
|
|
68
|
+
const span = log.span("db:query", { table: "users" })
|
|
69
|
+
try {
|
|
70
|
+
const users = await db.query("SELECT * FROM users")
|
|
71
|
+
span.spanData.count = users.length
|
|
72
|
+
} finally {
|
|
73
|
+
span.end()
|
|
74
|
+
}
|
|
42
75
|
```
|
|
43
76
|
|
|
44
|
-
## Why
|
|
77
|
+
## Why Loggily?
|
|
78
|
+
|
|
79
|
+
One API for debug-style namespace logging, structured JSON output, and lightweight spans. Many projects end up with separate tools for these -- **debug** for conditional output, **pino/winston** for production logs, a tracing SDK for timings -- with separate configs, formats, and APIs. Loggily integrates all three into one namespace tree, one output pipeline, one `?.` pattern.
|
|
80
|
+
|
|
81
|
+
### Near-zero cost for disabled logs
|
|
45
82
|
|
|
46
|
-
|
|
83
|
+
Most loggers waste work when logging is disabled. Even with a noop function, arguments are still evaluated:
|
|
47
84
|
|
|
48
85
|
```typescript
|
|
49
86
|
// Traditional — args are ALWAYS evaluated, even when debug is off
|
|
50
87
|
log.debug(`state: ${JSON.stringify(computeExpensiveState())}`)
|
|
51
88
|
```
|
|
52
89
|
|
|
53
|
-
|
|
90
|
+
Loggily uses optional chaining to skip the entire call — including argument evaluation:
|
|
54
91
|
|
|
55
92
|
```typescript
|
|
56
|
-
//
|
|
93
|
+
// Loggily — args are NOT evaluated when disabled
|
|
57
94
|
log.debug?.(`state: ${JSON.stringify(computeExpensiveState())}`)
|
|
58
95
|
```
|
|
59
96
|
|
|
60
97
|
For trivial arguments the difference is negligible. But for real-world logging — string interpolation, JSON serialization, state snapshots — optional chaining is typically **10x+ faster** because it skips the work entirely. The more expensive your arguments, the bigger the win.
|
|
61
98
|
|
|
99
|
+
> **Note**: The big performance advantage is specifically for disabled logging with expensive arguments, not universal logger throughput. Pino is optimized for high-throughput enabled JSON logging; Loggily's biggest advantage is skipping work when logs are disabled. See [benchmarks](https://beorn.codes/loggily/guide/benchmarks) for detailed numbers per scenario.
|
|
100
|
+
|
|
62
101
|
## Features
|
|
63
102
|
|
|
64
103
|
- **Namespace hierarchy** — organize logs with `:` separators. `log.logger("db")` creates `myapp:db`. Children inherit parent context.
|
|
65
|
-
- **
|
|
104
|
+
- **Lightweight spans and trace IDs** — time any operation with `using span = log.span("name")`. Automatic duration, parent-child tracking, and trace IDs. For full OpenTelemetry interoperability with exporters and propagation, use OpenTelemetry.
|
|
66
105
|
- **Lazy messages** — `log.debug?.(() => expensiveString())` skips the function entirely when disabled.
|
|
67
106
|
- **Child context** — `log.child({ requestId })` adds structured fields to every message in the chain.
|
|
68
107
|
- **Dev & production** — colorized console with timestamps, level colors, and clickable source lines in development. Structured JSON in production. Switches automatically via `NODE_ENV` — same code, zero config.
|
|
69
108
|
- **File writer** — `addWriter()` + `createFileWriter()` for buffered file output with auto-flush.
|
|
70
109
|
- **Worker threads** — forward logs from workers to the main thread with full type safety (`loggily/worker`).
|
|
71
|
-
- **
|
|
110
|
+
- **debug-compatible namespace filtering** — reads `DEBUG=myapp:*` just like the debug package. Easy migration from debug — see the [migration guide](https://beorn.codes/loggily/guide/migration-from-debug).
|
|
111
|
+
|
|
112
|
+
## When Not to Use Loggily
|
|
113
|
+
|
|
114
|
+
- **Max-throughput transport pipelines** — use [Pino](https://getpino.io/) for worker-thread transports, custom serializers, and log rotation.
|
|
115
|
+
- **Vendor/exporter interop** — use [OpenTelemetry](https://opentelemetry.io/) for distributed tracing with propagation, semantic conventions, and backend integrations.
|
|
116
|
+
- **Tiny dev-only namespace logs** — use [debug](https://github.com/debug-js/debug) if all you need is conditional dev output with zero ceremony.
|
|
72
117
|
|
|
73
118
|
## Documentation
|
|
74
119
|
|
|
75
|
-
- **[
|
|
120
|
+
- **[Get Started](https://beorn.codes/loggily/guide/journey)** — progressive guide from first log to full observability
|
|
76
121
|
- **[Full docs site](https://beorn.codes/loggily/)** — guides, API reference, migration guides
|
|
77
|
-
- [Comparison](
|
|
78
|
-
- [Migration from debug](
|
|
122
|
+
- [Comparison](https://beorn.codes/loggily/guide/comparison) — vs Pino, Winston, Bunyan, debug
|
|
123
|
+
- [Migration from debug](https://beorn.codes/loggily/guide/migration-from-debug) — step-by-step migration guide
|
|
79
124
|
|
|
80
125
|
## Environment Variables
|
|
81
126
|
|
|
@@ -101,7 +146,7 @@ For trivial arguments the difference is negligible. But for real-world logging
|
|
|
101
146
|
| `setLogLevel()` / `setLogFormat()` / `enableSpans()` | Runtime configuration |
|
|
102
147
|
| `createWorkerLogger()` / `createWorkerLogHandler()` | Worker thread support (`loggily/worker`) |
|
|
103
148
|
|
|
104
|
-
See the [full API reference](
|
|
149
|
+
See the [full API reference](https://beorn.codes/loggily/api/) for all functions and options.
|
|
105
150
|
|
|
106
151
|
## License
|
|
107
152
|
|
package/package.json
CHANGED
|
@@ -1,27 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loggily",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "TypeScript logger with debug-style namespaces, structured JSON output, and lightweight spans. Disabled logs skip argument evaluation via optional chaining.",
|
|
5
5
|
"keywords": [
|
|
6
|
+
"browser",
|
|
7
|
+
"bun",
|
|
8
|
+
"debug",
|
|
9
|
+
"json-logger",
|
|
6
10
|
"logger",
|
|
7
11
|
"logging",
|
|
12
|
+
"namespace",
|
|
13
|
+
"node",
|
|
14
|
+
"observability",
|
|
8
15
|
"optional-chaining",
|
|
9
16
|
"spans",
|
|
10
|
-
"structured",
|
|
17
|
+
"structured-logging",
|
|
11
18
|
"tracing",
|
|
12
|
-
"typescript"
|
|
13
|
-
"zero-overhead"
|
|
19
|
+
"typescript"
|
|
14
20
|
],
|
|
15
|
-
"homepage": "https://beorn.codes/
|
|
21
|
+
"homepage": "https://beorn.codes/loggily/",
|
|
16
22
|
"bugs": {
|
|
17
|
-
"url": "https://github.com/beorn/
|
|
23
|
+
"url": "https://github.com/beorn/loggily/issues"
|
|
18
24
|
},
|
|
19
25
|
"license": "MIT",
|
|
20
26
|
"author": "Beorn",
|
|
21
27
|
"repository": {
|
|
22
28
|
"type": "git",
|
|
23
|
-
"url": "https://github.com/beorn/
|
|
29
|
+
"url": "https://github.com/beorn/loggily.git"
|
|
24
30
|
},
|
|
31
|
+
"files": [
|
|
32
|
+
"src",
|
|
33
|
+
"dist"
|
|
34
|
+
],
|
|
25
35
|
"type": "module",
|
|
26
36
|
"module": "src/index.ts",
|
|
27
37
|
"types": "./dist/index.d.ts",
|
|
@@ -48,13 +58,16 @@
|
|
|
48
58
|
"default": "./src/tracing.ts"
|
|
49
59
|
}
|
|
50
60
|
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
},
|
|
51
64
|
"scripts": {
|
|
52
65
|
"build": "tsc",
|
|
53
66
|
"typecheck": "tsc --noEmit",
|
|
54
67
|
"test": "bunx --bun vitest run",
|
|
55
|
-
"docs:dev": "vitepress dev docs
|
|
56
|
-
"docs:build": "vitepress build docs
|
|
57
|
-
"docs:preview": "vitepress preview docs
|
|
68
|
+
"docs:dev": "vitepress dev docs",
|
|
69
|
+
"docs:build": "vitepress build docs",
|
|
70
|
+
"docs:preview": "vitepress preview docs"
|
|
58
71
|
},
|
|
59
72
|
"devDependencies": {
|
|
60
73
|
"@types/node": "^25.3.3",
|
package/src/context.ts
CHANGED
|
@@ -41,6 +41,13 @@ export interface SpanContext {
|
|
|
41
41
|
let storage: AsyncLocalStorage<SpanContext> | null = null
|
|
42
42
|
let contextEnabled = false
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Map from spanId → the SpanContext that was active when the span was entered.
|
|
46
|
+
* Used to restore the exact previous context on exit, avoiding corruption
|
|
47
|
+
* from non-LIFO end() ordering.
|
|
48
|
+
*/
|
|
49
|
+
const previousContexts = new Map<string, SpanContext | null>()
|
|
50
|
+
|
|
44
51
|
// ============ API ============
|
|
45
52
|
|
|
46
53
|
/**
|
|
@@ -99,30 +106,38 @@ export function getCurrentSpan(): SpanContext | null {
|
|
|
99
106
|
* spans with `using` — since `using` doesn't wrap user code in a callback,
|
|
100
107
|
* `enterWith()` is the right primitive.
|
|
101
108
|
*
|
|
109
|
+
* Captures the full previous SpanContext snapshot so it can be restored
|
|
110
|
+
* exactly on exit, even with non-LIFO end() ordering.
|
|
111
|
+
*
|
|
102
112
|
* @internal
|
|
103
113
|
*/
|
|
104
114
|
export function enterSpanContext(spanId: string, traceId: string, parentId: string | null): void {
|
|
105
115
|
if (!contextEnabled || !storage) return
|
|
116
|
+
|
|
117
|
+
// Capture the full previous context before overwriting
|
|
118
|
+
const previous = storage.getStore() ?? null
|
|
119
|
+
previousContexts.set(spanId, previous)
|
|
120
|
+
|
|
106
121
|
storage.enterWith({ spanId, traceId, parentId })
|
|
107
122
|
}
|
|
108
123
|
|
|
109
124
|
/**
|
|
110
|
-
* Restore the
|
|
111
|
-
*
|
|
125
|
+
* Restore the previous span context (called when a span ends).
|
|
126
|
+
* Restores the exact SpanContext snapshot captured at enter time,
|
|
127
|
+
* preventing corruption from non-LIFO end() ordering.
|
|
112
128
|
*
|
|
113
129
|
* @internal
|
|
114
130
|
*/
|
|
115
|
-
export function exitSpanContext(
|
|
131
|
+
export function exitSpanContext(spanId: string): void {
|
|
116
132
|
if (!contextEnabled || !storage) return
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
133
|
+
|
|
134
|
+
const previous = previousContexts.get(spanId)
|
|
135
|
+
previousContexts.delete(spanId)
|
|
136
|
+
|
|
137
|
+
if (previous) {
|
|
138
|
+
storage.enterWith(previous)
|
|
122
139
|
} else {
|
|
123
|
-
// No
|
|
124
|
-
// enterWith(undefined as any) is not ideal, but there's no "exitWith"
|
|
125
|
-
// We use a sentinel to indicate "no active span"
|
|
140
|
+
// No previous context — exit entirely
|
|
126
141
|
storage.enterWith(undefined as unknown as SpanContext)
|
|
127
142
|
}
|
|
128
143
|
}
|
package/src/core.ts
CHANGED
|
@@ -179,26 +179,39 @@ if (traceEnv && traceEnv !== "1" && traceEnv !== "true") {
|
|
|
179
179
|
|
|
180
180
|
// Debug namespace filter (DEBUG=myapp or DEBUG=myapp,-myapp:noisy or DEBUG=*)
|
|
181
181
|
// Supports negative patterns with `-` prefix (like the `debug` npm package)
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
182
|
+
|
|
183
|
+
/** Parse a comma-separated namespace filter into include/exclude sets */
|
|
184
|
+
function parseNamespaceFilter(input: string[]): {
|
|
185
|
+
includes: Set<string> | null
|
|
186
|
+
excludes: Set<string> | null
|
|
187
|
+
} {
|
|
188
|
+
const includeList: string[] = []
|
|
189
|
+
const excludeList: string[] = []
|
|
190
|
+
for (const part of input) {
|
|
190
191
|
if (part.startsWith("-")) {
|
|
191
|
-
|
|
192
|
+
excludeList.push(part.slice(1))
|
|
192
193
|
} else {
|
|
193
|
-
|
|
194
|
+
includeList.push(part)
|
|
194
195
|
}
|
|
195
196
|
}
|
|
196
|
-
|
|
197
|
-
|
|
197
|
+
return {
|
|
198
|
+
includes: includeList.length > 0 ? new Set(includeList) : null,
|
|
199
|
+
excludes: excludeList.length > 0 ? new Set(excludeList) : null,
|
|
198
200
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const debugEnv = getEnv("DEBUG")
|
|
204
|
+
let debugIncludes: Set<string> | null = null
|
|
205
|
+
let debugExcludes: Set<string> | null = null
|
|
206
|
+
if (debugEnv) {
|
|
207
|
+
const parts = debugEnv.split(",").map((s) => s.trim())
|
|
208
|
+
const parsed = parseNamespaceFilter(parts)
|
|
209
|
+
debugIncludes = parsed.includes
|
|
210
|
+
// Normalize wildcard variants
|
|
211
|
+
if (debugIncludes && [...debugIncludes].some((p) => p === "*" || p === "1" || p === "true")) {
|
|
212
|
+
debugIncludes = new Set(["*"])
|
|
201
213
|
}
|
|
214
|
+
debugExcludes = parsed.excludes
|
|
202
215
|
// Auto-lower log level to at least debug when DEBUG is set
|
|
203
216
|
if (LOG_LEVEL_PRIORITY[currentLogLevel] > LOG_LEVEL_PRIORITY.debug) {
|
|
204
217
|
currentLogLevel = "debug"
|
|
@@ -261,17 +274,9 @@ export function setDebugFilter(namespaces: string[] | null): void {
|
|
|
261
274
|
debugIncludes = null
|
|
262
275
|
debugExcludes = null
|
|
263
276
|
} else {
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
if (ns.startsWith("-")) {
|
|
268
|
-
excludes.push(ns.slice(1))
|
|
269
|
-
} else {
|
|
270
|
-
includes.push(ns)
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
debugIncludes = includes.length > 0 ? new Set(includes) : null
|
|
274
|
-
debugExcludes = excludes.length > 0 ? new Set(excludes) : null
|
|
277
|
+
const parsed = parseNamespaceFilter(namespaces)
|
|
278
|
+
debugIncludes = parsed.includes
|
|
279
|
+
debugExcludes = parsed.excludes
|
|
275
280
|
if (LOG_LEVEL_PRIORITY[currentLogLevel] > LOG_LEVEL_PRIORITY.debug) {
|
|
276
281
|
currentLogLevel = "debug"
|
|
277
282
|
}
|
|
@@ -334,8 +339,8 @@ let _getContextParent: (() => { spanId: string; traceId: string } | null) | null
|
|
|
334
339
|
/** Hook to enter a span context (sets AsyncLocalStorage for the current async scope) */
|
|
335
340
|
let _enterContext: ((spanId: string, traceId: string, parentId: string | null) => void) | null = null
|
|
336
341
|
|
|
337
|
-
/** Hook to exit a span context (restores
|
|
338
|
-
let _exitContext: ((
|
|
342
|
+
/** Hook to exit a span context (restores previous context snapshot) */
|
|
343
|
+
let _exitContext: ((spanId: string) => void) | null = null
|
|
339
344
|
|
|
340
345
|
/**
|
|
341
346
|
* Register context propagation hooks (called by context.ts).
|
|
@@ -345,7 +350,7 @@ export function _setContextHooks(hooks: {
|
|
|
345
350
|
getContextTags: () => Record<string, string>
|
|
346
351
|
getContextParent: () => { spanId: string; traceId: string } | null
|
|
347
352
|
enterContext: (spanId: string, traceId: string, parentId: string | null) => void
|
|
348
|
-
exitContext: (
|
|
353
|
+
exitContext: (spanId: string) => void
|
|
349
354
|
}): void {
|
|
350
355
|
_getContextTags = hooks.getContextTags
|
|
351
356
|
_getContextParent = hooks.getContextParent
|
|
@@ -376,6 +381,24 @@ function shouldTraceNamespace(namespace: string): boolean {
|
|
|
376
381
|
return matchesNamespaceSet(namespace, traceFilter)
|
|
377
382
|
}
|
|
378
383
|
|
|
384
|
+
/**
|
|
385
|
+
* Safe JSON.stringify that handles bigint, circular refs, symbols, and Error objects.
|
|
386
|
+
* Prevents crashes from non-serializable values in log data.
|
|
387
|
+
*/
|
|
388
|
+
function safeStringify(value: unknown): string {
|
|
389
|
+
const seen = new WeakSet()
|
|
390
|
+
return JSON.stringify(value, (_key, val) => {
|
|
391
|
+
if (typeof val === "bigint") return val.toString()
|
|
392
|
+
if (typeof val === "symbol") return val.toString()
|
|
393
|
+
if (val instanceof Error) return { message: val.message, stack: val.stack, name: val.name }
|
|
394
|
+
if (typeof val === "object" && val !== null) {
|
|
395
|
+
if (seen.has(val)) return "[Circular]"
|
|
396
|
+
seen.add(val)
|
|
397
|
+
}
|
|
398
|
+
return val
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
|
|
379
402
|
function formatConsole(namespace: string, level: string, message: string, data?: Record<string, unknown>): string {
|
|
380
403
|
const time = pc.dim(new Date().toISOString().split("T")[1]?.split(".")[0] || "")
|
|
381
404
|
|
|
@@ -405,7 +428,7 @@ function formatConsole(namespace: string, level: string, message: string, data?:
|
|
|
405
428
|
let output = `${time} ${levelStr} ${ns} ${message}`
|
|
406
429
|
|
|
407
430
|
if (data && Object.keys(data).length > 0) {
|
|
408
|
-
output += ` ${pc.dim(
|
|
431
|
+
output += ` ${pc.dim(safeStringify(data))}`
|
|
409
432
|
}
|
|
410
433
|
|
|
411
434
|
return output
|
|
@@ -419,14 +442,7 @@ function formatJSON(namespace: string, level: string, message: string, data?: Re
|
|
|
419
442
|
msg: message,
|
|
420
443
|
...data,
|
|
421
444
|
}
|
|
422
|
-
|
|
423
|
-
return JSON.stringify(entry, (_key, value) => {
|
|
424
|
-
if (typeof value === "object" && value !== null) {
|
|
425
|
-
if (seen.has(value)) return "[Circular]"
|
|
426
|
-
seen.add(value)
|
|
427
|
-
}
|
|
428
|
-
return value
|
|
429
|
-
})
|
|
445
|
+
return safeStringify(entry)
|
|
430
446
|
}
|
|
431
447
|
|
|
432
448
|
function matchesNamespaceSet(namespace: string, set: Set<string>): boolean {
|
|
@@ -502,7 +518,7 @@ function writeLog(
|
|
|
502
518
|
}
|
|
503
519
|
}
|
|
504
520
|
|
|
505
|
-
function writeSpan(namespace: string, duration: number, attrs: Record<string, unknown>): void {
|
|
521
|
+
export function writeSpan(namespace: string, duration: number, attrs: Record<string, unknown>): void {
|
|
506
522
|
if (!shouldTraceNamespace(namespace)) return
|
|
507
523
|
if (!shouldDebugNamespace(namespace)) return
|
|
508
524
|
|
|
@@ -515,6 +531,40 @@ function writeSpan(namespace: string, duration: number, attrs: Record<string, un
|
|
|
515
531
|
if (!suppressConsole) writeStderr(formatted)
|
|
516
532
|
}
|
|
517
533
|
|
|
534
|
+
// ============ Shared SpanData Proxy ============
|
|
535
|
+
|
|
536
|
+
interface SpanDataFields {
|
|
537
|
+
id: string
|
|
538
|
+
traceId: string
|
|
539
|
+
parentId: string | null
|
|
540
|
+
startTime: number
|
|
541
|
+
endTime: number | null
|
|
542
|
+
duration: number | null
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Create a proxy that exposes span metadata as readonly and custom attributes as writable.
|
|
547
|
+
* Shared between core logger spans and worker logger spans.
|
|
548
|
+
*/
|
|
549
|
+
export function createSpanDataProxy(getFields: () => SpanDataFields, attrs: Record<string, unknown>): SpanData {
|
|
550
|
+
const READONLY_KEYS = new Set(["id", "traceId", "parentId", "startTime", "endTime", "duration"])
|
|
551
|
+
return new Proxy(attrs, {
|
|
552
|
+
get(_target, prop) {
|
|
553
|
+
if (READONLY_KEYS.has(prop as string)) {
|
|
554
|
+
return getFields()[prop as keyof SpanDataFields]
|
|
555
|
+
}
|
|
556
|
+
return attrs[prop as string]
|
|
557
|
+
},
|
|
558
|
+
set(_target, prop, value) {
|
|
559
|
+
if (READONLY_KEYS.has(prop as string)) {
|
|
560
|
+
return false
|
|
561
|
+
}
|
|
562
|
+
attrs[prop as string] = value
|
|
563
|
+
return true
|
|
564
|
+
},
|
|
565
|
+
}) as SpanData
|
|
566
|
+
}
|
|
567
|
+
|
|
518
568
|
// ============ Implementation ============
|
|
519
569
|
|
|
520
570
|
interface MutableSpanData {
|
|
@@ -556,38 +606,17 @@ function createLoggerImpl(
|
|
|
556
606
|
|
|
557
607
|
get spanData(): SpanData | null {
|
|
558
608
|
if (!spanMeta) return null
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
}
|
|
571
|
-
return Date.now() - spanMeta.startTime
|
|
572
|
-
}
|
|
573
|
-
return spanMeta.attrs[prop as string]
|
|
574
|
-
},
|
|
575
|
-
set(_target, prop, value) {
|
|
576
|
-
// Allow setting custom attributes
|
|
577
|
-
if (
|
|
578
|
-
prop !== "id" &&
|
|
579
|
-
prop !== "traceId" &&
|
|
580
|
-
prop !== "parentId" &&
|
|
581
|
-
prop !== "startTime" &&
|
|
582
|
-
prop !== "endTime" &&
|
|
583
|
-
prop !== "duration"
|
|
584
|
-
) {
|
|
585
|
-
spanMeta.attrs[prop as string] = value
|
|
586
|
-
return true
|
|
587
|
-
}
|
|
588
|
-
return false
|
|
589
|
-
},
|
|
590
|
-
}) as SpanData
|
|
609
|
+
return createSpanDataProxy(
|
|
610
|
+
() => ({
|
|
611
|
+
id: spanMeta.id,
|
|
612
|
+
traceId: spanMeta.traceId,
|
|
613
|
+
parentId: spanMeta.parentId,
|
|
614
|
+
startTime: spanMeta.startTime,
|
|
615
|
+
endTime: spanMeta.endTime,
|
|
616
|
+
duration: spanMeta.endTime !== null ? spanMeta.endTime - spanMeta.startTime : Date.now() - spanMeta.startTime,
|
|
617
|
+
}),
|
|
618
|
+
spanMeta.attrs,
|
|
619
|
+
)
|
|
591
620
|
},
|
|
592
621
|
|
|
593
622
|
trace: (msg, data) => log("trace", msg, data),
|
|
@@ -655,8 +684,25 @@ function createLoggerImpl(
|
|
|
655
684
|
newSpanData.endTime = Date.now()
|
|
656
685
|
newSpanData.duration = newSpanData.endTime - newSpanData.startTime
|
|
657
686
|
|
|
658
|
-
//
|
|
659
|
-
|
|
687
|
+
// Collect span data if collection is active
|
|
688
|
+
if (collectSpans) {
|
|
689
|
+
collectedSpans.push(
|
|
690
|
+
createSpanDataProxy(
|
|
691
|
+
() => ({
|
|
692
|
+
id: newSpanData.id,
|
|
693
|
+
traceId: newSpanData.traceId,
|
|
694
|
+
parentId: newSpanData.parentId,
|
|
695
|
+
startTime: newSpanData.startTime,
|
|
696
|
+
endTime: newSpanData.endTime,
|
|
697
|
+
duration: newSpanData.duration,
|
|
698
|
+
}),
|
|
699
|
+
{ ...newSpanData.attrs },
|
|
700
|
+
),
|
|
701
|
+
)
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Exit span context (restore previous context snapshot)
|
|
705
|
+
_exitContext?.(newSpanId)
|
|
660
706
|
|
|
661
707
|
// Only emit span if sampled
|
|
662
708
|
if (sampled) {
|
package/src/file-writer.ts
CHANGED
|
@@ -60,8 +60,8 @@ export function createFileWriter(filePath: string, options: FileWriterOptions =
|
|
|
60
60
|
function flush(): void {
|
|
61
61
|
if (buffer.length === 0 || fd === null) return
|
|
62
62
|
const data = buffer
|
|
63
|
-
buffer = ""
|
|
64
63
|
writeSync(fd, data)
|
|
64
|
+
buffer = ""
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
// Set up periodic flush
|
|
@@ -93,12 +93,18 @@ export function createFileWriter(filePath: string, options: FileWriterOptions =
|
|
|
93
93
|
clearInterval(timer)
|
|
94
94
|
timer = null
|
|
95
95
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
try {
|
|
97
|
+
flush()
|
|
98
|
+
} catch {
|
|
99
|
+
// Swallow flush errors during close — data loss is unavoidable
|
|
100
|
+
// at this point, but we must still release the fd and exit handler.
|
|
101
|
+
} finally {
|
|
102
|
+
if (fd !== null) {
|
|
103
|
+
closeSync(fd)
|
|
104
|
+
fd = null
|
|
105
|
+
}
|
|
106
|
+
process.removeListener("exit", exitHandler)
|
|
100
107
|
}
|
|
101
|
-
process.removeListener("exit", exitHandler)
|
|
102
108
|
},
|
|
103
109
|
}
|
|
104
110
|
}
|
package/src/index.browser.ts
CHANGED
|
@@ -51,7 +51,15 @@ export {
|
|
|
51
51
|
} from "./core.js"
|
|
52
52
|
|
|
53
53
|
// Tracing utilities (runtime-agnostic, work in browser)
|
|
54
|
-
export {
|
|
54
|
+
export {
|
|
55
|
+
setIdFormat,
|
|
56
|
+
getIdFormat,
|
|
57
|
+
type IdFormat,
|
|
58
|
+
traceparent,
|
|
59
|
+
type TraceparentOptions,
|
|
60
|
+
setSampleRate,
|
|
61
|
+
getSampleRate,
|
|
62
|
+
} from "./tracing.js"
|
|
55
63
|
|
|
56
64
|
// File writer types (exported for type compatibility, but the function throws)
|
|
57
65
|
export type { FileWriterOptions, FileWriter } from "./file-writer.js"
|
package/src/index.ts
CHANGED
|
@@ -7,4 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
export * from "./core.js"
|
|
9
9
|
export { createFileWriter, type FileWriter, type FileWriterOptions } from "./file-writer.js"
|
|
10
|
-
export {
|
|
10
|
+
export {
|
|
11
|
+
setIdFormat,
|
|
12
|
+
getIdFormat,
|
|
13
|
+
type IdFormat,
|
|
14
|
+
traceparent,
|
|
15
|
+
type TraceparentOptions,
|
|
16
|
+
setSampleRate,
|
|
17
|
+
getSampleRate,
|
|
18
|
+
} from "./tracing.js"
|
package/src/tracing.ts
CHANGED
|
@@ -64,6 +64,12 @@ export function resetIdCounters(): void {
|
|
|
64
64
|
|
|
65
65
|
// ============ W3C Traceparent ============
|
|
66
66
|
|
|
67
|
+
/** Options for traceparent header formatting */
|
|
68
|
+
export interface TraceparentOptions {
|
|
69
|
+
/** Whether this span is sampled. Defaults to true for backwards compatibility. */
|
|
70
|
+
sampled?: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
67
73
|
/**
|
|
68
74
|
* Format a W3C traceparent header from span data.
|
|
69
75
|
*
|
|
@@ -71,11 +77,12 @@ export function resetIdCounters(): void {
|
|
|
71
77
|
* - version: "00" (current W3C spec version)
|
|
72
78
|
* - trace-id: 32 hex chars (128 bits)
|
|
73
79
|
* - span-id: 16 hex chars (64 bits)
|
|
74
|
-
* - trace-flags: "01" (sampled)
|
|
80
|
+
* - trace-flags: "01" (sampled) or "00" (not sampled)
|
|
75
81
|
*
|
|
76
82
|
* Works with both simple and W3C ID formats. Simple IDs are zero-padded to spec length.
|
|
77
83
|
*
|
|
78
84
|
* @param spanData - Span data with id and traceId
|
|
85
|
+
* @param options - Optional settings (sampled flag). Defaults to sampled=true.
|
|
79
86
|
* @returns W3C traceparent header string
|
|
80
87
|
*
|
|
81
88
|
* @example
|
|
@@ -86,10 +93,11 @@ export function resetIdCounters(): void {
|
|
|
86
93
|
* fetch(url, { headers: { traceparent: header } })
|
|
87
94
|
* ```
|
|
88
95
|
*/
|
|
89
|
-
export function traceparent(spanData: SpanData): string {
|
|
96
|
+
export function traceparent(spanData: SpanData, options?: TraceparentOptions): string {
|
|
90
97
|
const traceId = padHex(spanData.traceId, 32)
|
|
91
98
|
const spanId = padHex(spanData.id, 16)
|
|
92
|
-
|
|
99
|
+
const flags = (options?.sampled ?? true) ? "01" : "00"
|
|
100
|
+
return `00-${traceId}-${spanId}-${flags}`
|
|
93
101
|
}
|
|
94
102
|
|
|
95
103
|
/** Pad or hash an ID to the specified hex length */
|