loggily 0.6.0 → 0.6.2

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.
@@ -1,110 +0,0 @@
1
- /**
2
- * File writer for loggily — Node.js/Bun only.
3
- *
4
- * Separated from core logger to allow tree-shaking in browser bundles.
5
- * Uses dynamic import("node:fs") to avoid static dependency on Node APIs.
6
- */
7
-
8
- import { openSync, writeSync, closeSync } from "node:fs"
9
-
10
- /** Options for creating an async buffered file writer */
11
- export interface FileWriterOptions {
12
- /** Buffer size threshold in bytes before flushing (default: 4096) */
13
- bufferSize?: number
14
- /** Flush interval in milliseconds (default: 100) */
15
- flushInterval?: number
16
- }
17
-
18
- /** An async buffered file writer with automatic flushing */
19
- export interface FileWriter {
20
- /** Write a line to the buffer (appends newline) */
21
- write(line: string): void
22
- /** Flush the buffer immediately */
23
- flush(): void
24
- /** Close the writer and flush remaining buffer */
25
- close(): void
26
- }
27
-
28
- /**
29
- * Create an async buffered file writer for log output.
30
- * Buffers writes and flushes on size threshold or interval.
31
- * Registers a process.on('exit') handler to flush remaining buffer.
32
- *
33
- * **Node.js/Bun only** — not available in browser environments.
34
- *
35
- * @param filePath - Path to the log file (opened in append mode)
36
- * @param options - Buffer size and flush interval configuration
37
- * @returns FileWriter with write, flush, and close methods
38
- *
39
- * @example
40
- * const writer = createFileWriter('/tmp/app.log')
41
- * const unsubscribe = addWriter((formatted) => writer.write(formatted))
42
- *
43
- * // On shutdown:
44
- * unsubscribe()
45
- * writer.close()
46
- */
47
- export function createFileWriter(filePath: string, options: FileWriterOptions = {}): FileWriter {
48
- const bufferSize = options.bufferSize ?? 4096
49
- const flushInterval = options.flushInterval ?? 100
50
-
51
- let buffer = ""
52
- let fd: number | null = null
53
- let timer: ReturnType<typeof setInterval> | null = null
54
- let closed = false
55
-
56
- // Open file in append mode
57
- fd = openSync(filePath, "a")
58
-
59
- /** Flush buffer contents to disk synchronously */
60
- function flush(): void {
61
- if (buffer.length === 0 || fd === null) return
62
- const data = buffer
63
- writeSync(fd, data)
64
- buffer = ""
65
- }
66
-
67
- // Set up periodic flush
68
- timer = setInterval(flush, flushInterval)
69
- // Don't let the timer keep the process alive
70
- if (timer && typeof timer === "object" && "unref" in timer) {
71
- ;(timer as { unref(): void }).unref()
72
- }
73
-
74
- // Flush on process exit to avoid data loss
75
- const exitHandler = (): void => flush()
76
- process.on("exit", exitHandler)
77
-
78
- return {
79
- write(line: string): void {
80
- if (closed) return
81
- buffer += line + "\n"
82
- if (buffer.length >= bufferSize) {
83
- flush()
84
- }
85
- },
86
-
87
- flush,
88
-
89
- close(): void {
90
- if (closed) return
91
- closed = true
92
- if (timer !== null) {
93
- clearInterval(timer)
94
- timer = null
95
- }
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)
107
- }
108
- },
109
- }
110
- }
@@ -1,72 +0,0 @@
1
- /**
2
- * loggily browser entry point.
3
- *
4
- * Re-exports the full logger API except createFileWriter (which requires node:fs).
5
- * Bundlers resolve this via the "browser" condition in package.json exports.
6
- */
7
-
8
- // Re-export everything from core logger
9
- export {
10
- // Types
11
- type OutputLogLevel,
12
- type LogLevel,
13
- type LazyMessage,
14
- type SpanData,
15
- type Logger,
16
- type SpanLogger,
17
- type OutputMode,
18
- type LogFormat,
19
- type ConditionalLogger,
20
-
21
- // Writers
22
- addWriter,
23
- setSuppressConsole,
24
- setOutputMode,
25
- getOutputMode,
26
-
27
- // Configuration
28
- setLogLevel,
29
- getLogLevel,
30
- enableSpans,
31
- disableSpans,
32
- spansAreEnabled,
33
- setTraceFilter,
34
- getTraceFilter,
35
- setDebugFilter,
36
- getDebugFilter,
37
- setLogFormat,
38
- getLogFormat,
39
-
40
- // ID management
41
- resetIds,
42
-
43
- // Span collection
44
- startCollecting,
45
- stopCollecting,
46
- getCollectedSpans,
47
- clearCollectedSpans,
48
-
49
- // Logger creation
50
- createLogger,
51
- } from "./core.js"
52
-
53
- // Tracing utilities (runtime-agnostic, work in browser)
54
- export {
55
- setIdFormat,
56
- getIdFormat,
57
- type IdFormat,
58
- traceparent,
59
- type TraceparentOptions,
60
- setSampleRate,
61
- getSampleRate,
62
- } from "./tracing.js"
63
-
64
- // File writer types (exported for type compatibility, but the function throws)
65
- export type { FileWriterOptions, FileWriter } from "./file-writer.js"
66
-
67
- /** @throws Always — createFileWriter is not available in browser environments */
68
- export function createFileWriter(): never {
69
- throw new Error(
70
- "createFileWriter is not available in browser environments. Use addWriter() with a custom transport instead.",
71
- )
72
- }
package/src/index.ts DELETED
@@ -1,18 +0,0 @@
1
- /**
2
- * loggily - Structured logging with spans
3
- *
4
- * Full entry point for Node.js, Bun, and Deno.
5
- * Browser environments use index.browser.ts via the "browser" export condition.
6
- */
7
-
8
- export * from "./core.js"
9
- export { createFileWriter, type FileWriter, type FileWriterOptions } from "./file-writer.js"
10
- export {
11
- setIdFormat,
12
- getIdFormat,
13
- type IdFormat,
14
- traceparent,
15
- type TraceparentOptions,
16
- setSampleRate,
17
- getSampleRate,
18
- } from "./tracing.js"
package/src/metrics.ts DELETED
@@ -1,201 +0,0 @@
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
- }
package/src/tracing.ts DELETED
@@ -1,150 +0,0 @@
1
- /**
2
- * Distributed tracing utilities for loggily.
3
- *
4
- * Provides W3C-compatible trace/span ID generation, traceparent header formatting,
5
- * and head-based sampling. All features are opt-in and don't break the existing API.
6
- */
7
-
8
- import type { SpanData } from "./core.js"
9
-
10
- // ============ ID Format ============
11
-
12
- /** Supported ID formats */
13
- export type IdFormat = "simple" | "w3c"
14
-
15
- let currentIdFormat: IdFormat = "simple"
16
-
17
- /**
18
- * Set the ID format for new spans and traces.
19
- * - "simple": sp_1, sp_2, tr_1, tr_2 (default, lightweight)
20
- * - "w3c": 32-char hex trace ID, 16-char hex span ID (W3C Trace Context compatible)
21
- */
22
- export function setIdFormat(format: IdFormat): void {
23
- currentIdFormat = format
24
- }
25
-
26
- /** Get the current ID format */
27
- export function getIdFormat(): IdFormat {
28
- return currentIdFormat
29
- }
30
-
31
- // Simple format counters (used by core.ts via the generator functions)
32
- let simpleSpanCounter = 0
33
- let simpleTraceCounter = 0
34
-
35
- /** Generate a hex string of the given byte length using crypto.randomUUID */
36
- function randomHex(bytes: number): string {
37
- // crypto.randomUUID() gives us 32 hex chars (128 bits) after removing dashes
38
- // For 16 bytes (32 hex chars) we use one UUID, for 8 bytes (16 hex chars) we take a slice
39
- const uuid = crypto.randomUUID().replace(/-/g, "")
40
- return uuid.slice(0, bytes * 2)
41
- }
42
-
43
- /** Generate a span ID according to the current format */
44
- export function generateSpanId(): string {
45
- if (currentIdFormat === "w3c") {
46
- return randomHex(8) // 16-char hex
47
- }
48
- return `sp_${(++simpleSpanCounter).toString(36)}`
49
- }
50
-
51
- /** Generate a trace ID according to the current format */
52
- export function generateTraceId(): string {
53
- if (currentIdFormat === "w3c") {
54
- return randomHex(16) // 32-char hex
55
- }
56
- return `tr_${(++simpleTraceCounter).toString(36)}`
57
- }
58
-
59
- /** Reset ID counters (for testing) */
60
- export function resetIdCounters(): void {
61
- simpleSpanCounter = 0
62
- simpleTraceCounter = 0
63
- }
64
-
65
- // ============ W3C Traceparent ============
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
-
73
- /**
74
- * Format a W3C traceparent header from span data.
75
- *
76
- * Format: `{version}-{trace-id}-{span-id}-{trace-flags}`
77
- * - version: "00" (current W3C spec version)
78
- * - trace-id: 32 hex chars (128 bits)
79
- * - span-id: 16 hex chars (64 bits)
80
- * - trace-flags: "01" (sampled) or "00" (not sampled)
81
- *
82
- * Works with both simple and W3C ID formats. Simple IDs are zero-padded to spec length.
83
- *
84
- * @param spanData - Span data with id and traceId
85
- * @param options - Optional settings (sampled flag). Defaults to sampled=true.
86
- * @returns W3C traceparent header string
87
- *
88
- * @example
89
- * ```typescript
90
- * const span = log.span("http-request")
91
- * const header = traceparent(span.spanData)
92
- * // → "00-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6-1a2b3c4d5e6f7a8b-01"
93
- * fetch(url, { headers: { traceparent: header } })
94
- * ```
95
- */
96
- export function traceparent(spanData: SpanData, options?: TraceparentOptions): string {
97
- const traceId = padHex(spanData.traceId, 32)
98
- const spanId = padHex(spanData.id, 16)
99
- const flags = (options?.sampled ?? true) ? "01" : "00"
100
- return `00-${traceId}-${spanId}-${flags}`
101
- }
102
-
103
- /** Pad or hash an ID to the specified hex length */
104
- function padHex(id: string, length: number): string {
105
- // If it's already the right length and looks like hex, use as-is
106
- if (id.length === length && /^[0-9a-f]+$/.test(id)) {
107
- return id
108
- }
109
-
110
- // For simple IDs (sp_1, tr_1), create a deterministic hex representation
111
- // by encoding the string as hex bytes, zero-padded to the target length
112
- let hex = ""
113
- for (let i = 0; i < id.length; i++) {
114
- hex += id.charCodeAt(i).toString(16).padStart(2, "0")
115
- }
116
- // Pad or truncate to target length
117
- return hex.padStart(length, "0").slice(-length)
118
- }
119
-
120
- // ============ Sampling ============
121
-
122
- let sampleRate = 1.0
123
-
124
- /**
125
- * Set the head-based sampling rate for new traces.
126
- * Applied at trace creation — all spans within a sampled trace are kept.
127
- *
128
- * @param rate - Sampling rate from 0.0 (sample nothing) to 1.0 (sample everything, default)
129
- */
130
- export function setSampleRate(rate: number): void {
131
- if (rate < 0 || rate > 1) {
132
- throw new Error(`Sample rate must be between 0.0 and 1.0, got ${rate}`)
133
- }
134
- sampleRate = rate
135
- }
136
-
137
- /** Get the current sampling rate */
138
- export function getSampleRate(): number {
139
- return sampleRate
140
- }
141
-
142
- /**
143
- * Determine whether a new trace should be sampled.
144
- * Called at trace creation time (head-based sampling).
145
- */
146
- export function shouldSample(): boolean {
147
- if (sampleRate >= 1.0) return true
148
- if (sampleRate <= 0.0) return false
149
- return Math.random() < sampleRate
150
- }