loggily 0.4.2 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loggily",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
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",
@@ -55,6 +55,10 @@
55
55
  "./tracing": {
56
56
  "types": "./src/tracing.ts",
57
57
  "default": "./src/tracing.ts"
58
+ },
59
+ "./metrics": {
60
+ "types": "./src/metrics.ts",
61
+ "default": "./src/metrics.ts"
58
62
  }
59
63
  },
60
64
  "publishConfig": {
@@ -71,6 +75,8 @@
71
75
  "devDependencies": {
72
76
  "@types/node": "^25.5.0",
73
77
  "vitepress": "^1.6.4",
78
+ "vitepress-enrich": "^0.4.0",
79
+ "vitepress-plugin-llms": "^1.12.0",
74
80
  "vitest": "^4.1.1"
75
81
  },
76
82
  "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?: Record<string, unknown>): SpanLogger
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?: Record<string, unknown>): SpanLogger {
660
+ span(namespace?: string, childProps?: LazyProps): SpanLogger {
635
661
  const childName = namespace ? `${name}:${namespace}` : name
636
- const mergedProps = { ...props, ...childProps }
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?: Record<string, unknown>): SpanLogger
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
+ }