loggily 0.4.2 → 0.5.1
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 +1 -1
- package/package.json +7 -2
- package/src/core.ts +35 -5
- package/src/metrics.ts +201 -0
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ npm install loggily
|
|
|
18
18
|
|
|
19
19
|
| Requirement | Version |
|
|
20
20
|
| ------------- | ------------------------------------------------- |
|
|
21
|
-
| Node.js |
|
|
21
|
+
| Node.js | >= 23.6 |
|
|
22
22
|
| Bun | 1.0+ |
|
|
23
23
|
| TypeScript | 5.2+ (for `using`; `.end()` works on any version) |
|
|
24
24
|
| Module format | ESM-only |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loggily",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
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
6
|
"browser",
|
|
@@ -32,7 +32,6 @@
|
|
|
32
32
|
"src"
|
|
33
33
|
],
|
|
34
34
|
"type": "module",
|
|
35
|
-
"module": "src/index.ts",
|
|
36
35
|
"types": "./src/index.ts",
|
|
37
36
|
"exports": {
|
|
38
37
|
".": {
|
|
@@ -55,6 +54,10 @@
|
|
|
55
54
|
"./tracing": {
|
|
56
55
|
"types": "./src/tracing.ts",
|
|
57
56
|
"default": "./src/tracing.ts"
|
|
57
|
+
},
|
|
58
|
+
"./metrics": {
|
|
59
|
+
"types": "./src/metrics.ts",
|
|
60
|
+
"default": "./src/metrics.ts"
|
|
58
61
|
}
|
|
59
62
|
},
|
|
60
63
|
"publishConfig": {
|
|
@@ -71,6 +74,8 @@
|
|
|
71
74
|
"devDependencies": {
|
|
72
75
|
"@types/node": "^25.5.0",
|
|
73
76
|
"vitepress": "^1.6.4",
|
|
77
|
+
"vitepress-enrich": "^0.4.0",
|
|
78
|
+
"vitepress-plugin-llms": "^1.12.0",
|
|
74
79
|
"vitest": "^4.1.1"
|
|
75
80
|
},
|
|
76
81
|
"engines": {
|
package/src/core.ts
CHANGED
|
@@ -27,6 +27,29 @@
|
|
|
27
27
|
|
|
28
28
|
import { colors as pc } from "./colors.js"
|
|
29
29
|
|
|
30
|
+
// ============ Metrics ============
|
|
31
|
+
|
|
32
|
+
/** Data passed to span recorders on disposal */
|
|
33
|
+
export interface SpanRecord {
|
|
34
|
+
readonly name: string
|
|
35
|
+
readonly durationMs: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Interface for span duration recording — implemented by createMetricsCollector() in metrics.ts */
|
|
39
|
+
export interface SpanRecorder {
|
|
40
|
+
recordSpan(data: SpanRecord): void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Ambient span recorder — auto-records when TRACE is active.
|
|
45
|
+
* Set by metrics.ts on import; can be replaced for testing.
|
|
46
|
+
* @internal
|
|
47
|
+
*/
|
|
48
|
+
export let _ambientRecorder: SpanRecorder | null = null
|
|
49
|
+
export function _setAmbientRecorder(recorder: SpanRecorder | null): void {
|
|
50
|
+
_ambientRecorder = recorder
|
|
51
|
+
}
|
|
52
|
+
|
|
30
53
|
// ============ Runtime Detection ============
|
|
31
54
|
|
|
32
55
|
/** Cached process reference — undefined in browser/edge runtimes */
|
|
@@ -57,6 +80,9 @@ export type LogLevel = OutputLogLevel | "silent"
|
|
|
57
80
|
/** Message can be a string or a lazy function that returns a string */
|
|
58
81
|
export type LazyMessage = string | (() => string)
|
|
59
82
|
|
|
83
|
+
/** Span props can be an object or a lazy function (skipped entirely via ?. when tracing is off) */
|
|
84
|
+
export type LazyProps = Record<string, unknown> | (() => Record<string, unknown>)
|
|
85
|
+
|
|
60
86
|
/** Span data accessible via logger.spanData */
|
|
61
87
|
export interface SpanData {
|
|
62
88
|
readonly id: string
|
|
@@ -90,8 +116,8 @@ export interface Logger {
|
|
|
90
116
|
// Create children
|
|
91
117
|
/** Create child logger (extends namespace, inherits props) */
|
|
92
118
|
logger(namespace?: string, props?: Record<string, unknown>): Logger
|
|
93
|
-
/** Create child span (extends namespace, inherits props, adds timing) */
|
|
94
|
-
span(namespace?: string, props?:
|
|
119
|
+
/** Create child span (extends namespace, inherits props, adds timing). Props can be lazy. */
|
|
120
|
+
span(namespace?: string, props?: LazyProps): SpanLogger
|
|
95
121
|
|
|
96
122
|
/** Create child logger with context fields merged into every message */
|
|
97
123
|
child(context: Record<string, unknown>): Logger
|
|
@@ -631,9 +657,10 @@ function createLoggerImpl(
|
|
|
631
657
|
return createLoggerImpl(childName, mergedProps, null, parentSpanId, traceId, traceSampled)
|
|
632
658
|
},
|
|
633
659
|
|
|
634
|
-
span(namespace?: string, childProps?:
|
|
660
|
+
span(namespace?: string, childProps?: LazyProps): SpanLogger {
|
|
635
661
|
const childName = namespace ? `${name}:${namespace}` : name
|
|
636
|
-
const
|
|
662
|
+
const resolvedChildProps = typeof childProps === "function" ? childProps() : childProps
|
|
663
|
+
const mergedProps = { ...props, ...resolvedChildProps }
|
|
637
664
|
const newSpanId = generateSpanId()
|
|
638
665
|
|
|
639
666
|
// Resolve parent from context propagation if not explicitly set
|
|
@@ -704,6 +731,9 @@ function createLoggerImpl(
|
|
|
704
731
|
// Exit span context (restore previous context snapshot)
|
|
705
732
|
_exitContext?.(newSpanId)
|
|
706
733
|
|
|
734
|
+
// Record to ambient recorder (auto-active when TRACE is on, set by metrics.ts)
|
|
735
|
+
_ambientRecorder?.recordSpan({ name: childName, durationMs: newSpanData.duration })
|
|
736
|
+
|
|
707
737
|
// Only emit span if sampled
|
|
708
738
|
if (sampled) {
|
|
709
739
|
writeSpan(childName, newSpanData.duration, {
|
|
@@ -797,7 +827,7 @@ export interface ConditionalLogger {
|
|
|
797
827
|
}
|
|
798
828
|
|
|
799
829
|
logger(namespace?: string, props?: Record<string, unknown>): Logger
|
|
800
|
-
span(namespace?: string, props?:
|
|
830
|
+
span(namespace?: string, props?: LazyProps): SpanLogger
|
|
801
831
|
child(context: Record<string, unknown>): Logger
|
|
802
832
|
child(context: string): Logger
|
|
803
833
|
end(): void
|
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metrics collection for loggily spans.
|
|
3
|
+
*
|
|
4
|
+
* Two modes:
|
|
5
|
+
* - **Ambient**: import this module → spans auto-record when TRACE is active
|
|
6
|
+
* - **Explicit**: `withMetrics(collector?)(logger)` for custom collection
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { spanStats } from "loggily/metrics"
|
|
11
|
+
* // TRACE=myapp bun run app
|
|
12
|
+
* // → on exit: spanStats() returns aggregated p50/p95/p99
|
|
13
|
+
*
|
|
14
|
+
* // Custom collector:
|
|
15
|
+
* import { withMetrics, createMetricsCollector } from "loggily/metrics"
|
|
16
|
+
* const log = withMetrics()(createLogger("myapp"))
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
type SpanRecorder,
|
|
22
|
+
type SpanRecord,
|
|
23
|
+
type ConditionalLogger,
|
|
24
|
+
type Logger,
|
|
25
|
+
type LazyProps,
|
|
26
|
+
type SpanLogger,
|
|
27
|
+
_setAmbientRecorder,
|
|
28
|
+
spansAreEnabled,
|
|
29
|
+
} from "./core.js"
|
|
30
|
+
|
|
31
|
+
export type { SpanRecorder, SpanRecord }
|
|
32
|
+
|
|
33
|
+
// ============ Stats ============
|
|
34
|
+
|
|
35
|
+
export interface SpanStats {
|
|
36
|
+
count: number
|
|
37
|
+
min: number
|
|
38
|
+
max: number
|
|
39
|
+
mean: number
|
|
40
|
+
p50: number
|
|
41
|
+
p95: number
|
|
42
|
+
p99: number
|
|
43
|
+
total: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function percentile(sorted: number[], p: number): number {
|
|
47
|
+
if (sorted.length === 0) return 0
|
|
48
|
+
const idx = Math.min(Math.floor(sorted.length * p), sorted.length - 1)
|
|
49
|
+
return sorted[idx]!
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function computeStats(durations: number[]): SpanStats {
|
|
53
|
+
const sorted = [...durations].sort((a, b) => a - b)
|
|
54
|
+
const total = sorted.reduce((sum, d) => sum + d, 0)
|
|
55
|
+
return {
|
|
56
|
+
count: sorted.length,
|
|
57
|
+
min: sorted[0] ?? 0,
|
|
58
|
+
max: sorted[sorted.length - 1] ?? 0,
|
|
59
|
+
mean: sorted.length > 0 ? total / sorted.length : 0,
|
|
60
|
+
p50: percentile(sorted, 0.5),
|
|
61
|
+
p95: percentile(sorted, 0.95),
|
|
62
|
+
p99: percentile(sorted, 0.99),
|
|
63
|
+
total,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ============ Collector ============
|
|
68
|
+
|
|
69
|
+
export interface MetricsCollector extends SpanRecorder {
|
|
70
|
+
/** Get stats for a specific span namespace */
|
|
71
|
+
stats(name: string): SpanStats | undefined
|
|
72
|
+
/** Get stats for all recorded namespaces */
|
|
73
|
+
all(): Map<string, SpanStats>
|
|
74
|
+
/** Format a human-readable summary */
|
|
75
|
+
summary(): string
|
|
76
|
+
/** Reset all collected data */
|
|
77
|
+
reset(): void
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createMetricsCollector(maxEntries = 1000): MetricsCollector {
|
|
81
|
+
const store = new Map<string, number[]>()
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
recordSpan(data: SpanRecord): void {
|
|
85
|
+
let arr = store.get(data.name)
|
|
86
|
+
if (!arr) {
|
|
87
|
+
arr = []
|
|
88
|
+
store.set(data.name, arr)
|
|
89
|
+
}
|
|
90
|
+
arr.push(data.durationMs)
|
|
91
|
+
// Bound memory: keep last N entries per namespace
|
|
92
|
+
if (arr.length > maxEntries) arr.shift()
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
stats(name: string): SpanStats | undefined {
|
|
96
|
+
const arr = store.get(name)
|
|
97
|
+
if (!arr || arr.length === 0) return undefined
|
|
98
|
+
return computeStats(arr)
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
all(): Map<string, SpanStats> {
|
|
102
|
+
const result = new Map<string, SpanStats>()
|
|
103
|
+
for (const [name, durations] of store) {
|
|
104
|
+
if (durations.length > 0) result.set(name, computeStats(durations))
|
|
105
|
+
}
|
|
106
|
+
return result
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
summary(): string {
|
|
110
|
+
const entries = [...this.all().entries()]
|
|
111
|
+
if (entries.length === 0) return "(no span data)"
|
|
112
|
+
const lines = entries.map(
|
|
113
|
+
([name, s]) =>
|
|
114
|
+
`${name}: ${s.count} spans, mean=${s.mean.toFixed(1)}ms, p50=${s.p50.toFixed(1)}ms, p95=${s.p95.toFixed(1)}ms, p99=${s.p99.toFixed(1)}ms`,
|
|
115
|
+
)
|
|
116
|
+
return lines.join("\n")
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
reset(): void {
|
|
120
|
+
store.clear()
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ============ Ambient Collector ============
|
|
126
|
+
|
|
127
|
+
const _ambient = createMetricsCollector()
|
|
128
|
+
|
|
129
|
+
// Auto-activate ambient recording.
|
|
130
|
+
// Always set — the cost is one ?.recordSpan() call per span (negligible).
|
|
131
|
+
// The TRACE gate already controls whether spans are *created* at all.
|
|
132
|
+
_setAmbientRecorder(_ambient)
|
|
133
|
+
|
|
134
|
+
/** Get aggregated span stats (from ambient collector). */
|
|
135
|
+
export function spanStats(): Map<string, SpanStats> {
|
|
136
|
+
return _ambient.all()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Get the ambient collector's formatted summary. */
|
|
140
|
+
export function spanSummary(): string {
|
|
141
|
+
return _ambient.summary()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Reset the ambient collector. */
|
|
145
|
+
export function resetSpanStats(): void {
|
|
146
|
+
_ambient.reset()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ============ withMetrics ============
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Compose a logger with a metrics collector.
|
|
153
|
+
* Returns a curried wrapper: `withMetrics(collector?)(logger)`
|
|
154
|
+
*
|
|
155
|
+
* - No arg: uses the built-in ambient collector
|
|
156
|
+
* - Custom collector: records to your collector
|
|
157
|
+
* - Stackable: `withMetrics(a)(withMetrics(b)(logger))` fans out to both
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* ```typescript
|
|
161
|
+
* const log = withMetrics()(createLogger("myapp"))
|
|
162
|
+
* const log = withMetrics(myCollector)(createLogger("myapp"))
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
export function withMetrics(collector?: SpanRecorder): (logger: ConditionalLogger) => ConditionalLogger {
|
|
166
|
+
const recorder = collector ?? _ambient
|
|
167
|
+
|
|
168
|
+
return (logger: ConditionalLogger): ConditionalLogger => {
|
|
169
|
+
// Wrap the logger's span method to intercept disposal
|
|
170
|
+
return new Proxy(logger, {
|
|
171
|
+
get(target, prop: string | symbol) {
|
|
172
|
+
if (prop === "span") {
|
|
173
|
+
const originalSpan = target.span
|
|
174
|
+
if (!originalSpan) return undefined // TRACE off — preserve ?. behavior
|
|
175
|
+
return (namespace?: string, props?: LazyProps): SpanLogger => {
|
|
176
|
+
const span = originalSpan.call(target, namespace, props)
|
|
177
|
+
// Wrap disposal to record to our collector
|
|
178
|
+
const originalDispose = (span as unknown as { [Symbol.dispose]: () => void })[Symbol.dispose]
|
|
179
|
+
;(span as unknown as { [Symbol.dispose]: () => void })[Symbol.dispose] = () => {
|
|
180
|
+
originalDispose.call(span)
|
|
181
|
+
// After original disposal computed duration, record it
|
|
182
|
+
if (span.spanData?.duration != null) {
|
|
183
|
+
recorder.recordSpan({ name: span.name, durationMs: span.spanData.duration })
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return span
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (prop === "logger") {
|
|
190
|
+
// Child loggers inherit the metrics wrapper
|
|
191
|
+
return (namespace?: string, childProps?: Record<string, unknown>): Logger => {
|
|
192
|
+
const child = target.logger(namespace, childProps)
|
|
193
|
+
// Re-wrap the child — withMetrics(recorder) applied recursively
|
|
194
|
+
return withMetrics(recorder)(child as unknown as ConditionalLogger) as unknown as Logger
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return (target as unknown as Record<string | symbol, unknown>)[prop]
|
|
198
|
+
},
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
}
|