react-native-nitro-logger 0.1.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/LICENSE +20 -0
- package/NitroLogger.podspec +29 -0
- package/README.md +214 -0
- package/android/CMakeLists.txt +24 -0
- package/android/build.gradle +124 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/cpp-adapter.cpp +11 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/AndroidPlatformIo.kt +100 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/HybridFileSink.kt +292 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/HybridNativeConsoleSink.kt +23 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/LogFileWriter.kt +1328 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/LogRotationPolicy.kt +186 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/LogSecureFile.kt +80 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/LogWriterRegistry.kt +420 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/NativeConsoleWriter.kt +237 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/NitroLoggerPackage.kt +22 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/PlatformIo.kt +165 -0
- package/docs/PARITY.md +330 -0
- package/docs/PRIVACY.md +341 -0
- package/eslint-plugin/README.md +238 -0
- package/eslint-plugin/index.js +62 -0
- package/eslint-plugin/rules/literal-subsystem.js +102 -0
- package/eslint-plugin/rules/no-computed-metadata-key.js +174 -0
- package/eslint-plugin/rules/no-derived-correlation.js +171 -0
- package/eslint-plugin/rules/no-dynamic-message.js +60 -0
- package/eslint-plugin/shared.js +1948 -0
- package/ios/Gzip.swift +203 -0
- package/ios/HybridFileSink.swift +291 -0
- package/ios/HybridNativeConsoleSink.swift +20 -0
- package/ios/LogFileWriter.swift +1280 -0
- package/ios/LogSecureFile.swift +296 -0
- package/ios/LogWriterRegistry.swift +444 -0
- package/ios/NativeConsoleWriter.swift +180 -0
- package/lib/module/Logger.js +381 -0
- package/lib/module/Logger.js.map +1 -0
- package/lib/module/ScopedLogger.js +70 -0
- package/lib/module/ScopedLogger.js.map +1 -0
- package/lib/module/config.js +23 -0
- package/lib/module/config.js.map +1 -0
- package/lib/module/destinations/Batcher.js +647 -0
- package/lib/module/destinations/Batcher.js.map +1 -0
- package/lib/module/destinations/ConsoleDestination.js +51 -0
- package/lib/module/destinations/ConsoleDestination.js.map +1 -0
- package/lib/module/destinations/FileDestination.js +360 -0
- package/lib/module/destinations/FileDestination.js.map +1 -0
- package/lib/module/destinations/NativeConsoleDestination.js +170 -0
- package/lib/module/destinations/NativeConsoleDestination.js.map +1 -0
- package/lib/module/destinations/types.js +4 -0
- package/lib/module/destinations/types.js.map +1 -0
- package/lib/module/formatters/DefaultFormatter.js +136 -0
- package/lib/module/formatters/DefaultFormatter.js.map +1 -0
- package/lib/module/formatters/JsonLinesFormatter.js +162 -0
- package/lib/module/formatters/JsonLinesFormatter.js.map +1 -0
- package/lib/module/formatters/timestamp.js +15 -0
- package/lib/module/formatters/timestamp.js.map +1 -0
- package/lib/module/formatters/types.js +4 -0
- package/lib/module/formatters/types.js.map +1 -0
- package/lib/module/index.js +31 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/integrations/appState.js +81 -0
- package/lib/module/integrations/appState.js.map +1 -0
- package/lib/module/integrations/errorHandler.js +166 -0
- package/lib/module/integrations/errorHandler.js.map +1 -0
- package/lib/module/integrations/sanitizeError.js +238 -0
- package/lib/module/integrations/sanitizeError.js.map +1 -0
- package/lib/module/levels.js +35 -0
- package/lib/module/levels.js.map +1 -0
- package/lib/module/metadata.js +52 -0
- package/lib/module/metadata.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/privacy.js +349 -0
- package/lib/module/privacy.js.map +1 -0
- package/lib/module/specs/FileSink.nitro.js +4 -0
- package/lib/module/specs/FileSink.nitro.js.map +1 -0
- package/lib/module/specs/NativeConsoleSink.nitro.js +4 -0
- package/lib/module/specs/NativeConsoleSink.nitro.js.map +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/utf8.js +42 -0
- package/lib/module/utf8.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/Logger.d.ts +122 -0
- package/lib/typescript/src/Logger.d.ts.map +1 -0
- package/lib/typescript/src/ScopedLogger.d.ts +36 -0
- package/lib/typescript/src/ScopedLogger.d.ts.map +1 -0
- package/lib/typescript/src/config.d.ts +12 -0
- package/lib/typescript/src/config.d.ts.map +1 -0
- package/lib/typescript/src/destinations/Batcher.d.ts +244 -0
- package/lib/typescript/src/destinations/Batcher.d.ts.map +1 -0
- package/lib/typescript/src/destinations/ConsoleDestination.d.ts +27 -0
- package/lib/typescript/src/destinations/ConsoleDestination.d.ts.map +1 -0
- package/lib/typescript/src/destinations/FileDestination.d.ts +200 -0
- package/lib/typescript/src/destinations/FileDestination.d.ts.map +1 -0
- package/lib/typescript/src/destinations/NativeConsoleDestination.d.ts +86 -0
- package/lib/typescript/src/destinations/NativeConsoleDestination.d.ts.map +1 -0
- package/lib/typescript/src/destinations/types.d.ts +19 -0
- package/lib/typescript/src/destinations/types.d.ts.map +1 -0
- package/lib/typescript/src/formatters/DefaultFormatter.d.ts +22 -0
- package/lib/typescript/src/formatters/DefaultFormatter.d.ts.map +1 -0
- package/lib/typescript/src/formatters/JsonLinesFormatter.d.ts +73 -0
- package/lib/typescript/src/formatters/JsonLinesFormatter.d.ts.map +1 -0
- package/lib/typescript/src/formatters/timestamp.d.ts +6 -0
- package/lib/typescript/src/formatters/timestamp.d.ts.map +1 -0
- package/lib/typescript/src/formatters/types.d.ts +27 -0
- package/lib/typescript/src/formatters/types.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +34 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/integrations/appState.d.ts +33 -0
- package/lib/typescript/src/integrations/appState.d.ts.map +1 -0
- package/lib/typescript/src/integrations/errorHandler.d.ts +55 -0
- package/lib/typescript/src/integrations/errorHandler.d.ts.map +1 -0
- package/lib/typescript/src/integrations/sanitizeError.d.ts +89 -0
- package/lib/typescript/src/integrations/sanitizeError.d.ts.map +1 -0
- package/lib/typescript/src/levels.d.ts +9 -0
- package/lib/typescript/src/levels.d.ts.map +1 -0
- package/lib/typescript/src/metadata.d.ts +23 -0
- package/lib/typescript/src/metadata.d.ts.map +1 -0
- package/lib/typescript/src/privacy.d.ts +103 -0
- package/lib/typescript/src/privacy.d.ts.map +1 -0
- package/lib/typescript/src/specs/FileSink.nitro.d.ts +131 -0
- package/lib/typescript/src/specs/FileSink.nitro.d.ts.map +1 -0
- package/lib/typescript/src/specs/NativeConsoleSink.nitro.d.ts +21 -0
- package/lib/typescript/src/specs/NativeConsoleSink.nitro.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +37 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/utf8.d.ts +15 -0
- package/lib/typescript/src/utf8.d.ts.map +1 -0
- package/nitro.json +33 -0
- package/nitrogen/generated/android/c++/JAppendResult.hpp +79 -0
- package/nitrogen/generated/android/c++/JClearOutcome.hpp +88 -0
- package/nitrogen/generated/android/c++/JFlushOutcome.hpp +81 -0
- package/nitrogen/generated/android/c++/JHybridFileSinkSpec.cpp +120 -0
- package/nitrogen/generated/android/c++/JHybridFileSinkSpec.hpp +69 -0
- package/nitrogen/generated/android/c++/JHybridNativeConsoleSinkSpec.cpp +71 -0
- package/nitrogen/generated/android/c++/JHybridNativeConsoleSinkSpec.hpp +64 -0
- package/nitrogen/generated/android/c++/JRejectReason.hpp +64 -0
- package/nitrogen/generated/android/c++/JRotationConfig.hpp +77 -0
- package/nitrogen/generated/android/c++/JSinkStatus.hpp +69 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/AppendResult.kt +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/ClearOutcome.kt +66 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/FlushOutcome.kt +81 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/HybridFileSinkSpec.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/HybridNativeConsoleSinkSpec.kt +58 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/RejectReason.kt +25 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/RotationConfig.kt +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/SinkStatus.kt +66 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/nitrologgerOnLoad.kt +35 -0
- package/nitrogen/generated/android/nitrologger+autolinking.cmake +83 -0
- package/nitrogen/generated/android/nitrologger+autolinking.gradle +27 -0
- package/nitrogen/generated/android/nitrologgerOnLoad.cpp +70 -0
- package/nitrogen/generated/android/nitrologgerOnLoad.hpp +34 -0
- package/nitrogen/generated/ios/NitroLogger+autolinking.rb +62 -0
- package/nitrogen/generated/ios/NitroLogger-Swift-Cxx-Bridge.cpp +50 -0
- package/nitrogen/generated/ios/NitroLogger-Swift-Cxx-Bridge.hpp +216 -0
- package/nitrogen/generated/ios/NitroLogger-Swift-Cxx-Umbrella.hpp +69 -0
- package/nitrogen/generated/ios/NitroLoggerAutolinking.mm +41 -0
- package/nitrogen/generated/ios/NitroLoggerAutolinking.swift +38 -0
- package/nitrogen/generated/ios/c++/HybridFileSinkSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridFileSinkSpecSwift.hpp +150 -0
- package/nitrogen/generated/ios/c++/HybridNativeConsoleSinkSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridNativeConsoleSinkSpecSwift.hpp +87 -0
- package/nitrogen/generated/ios/swift/AppendResult.swift +60 -0
- package/nitrogen/generated/ios/swift/ClearOutcome.swift +50 -0
- package/nitrogen/generated/ios/swift/FlushOutcome.swift +59 -0
- package/nitrogen/generated/ios/swift/HybridFileSinkSpec.swift +61 -0
- package/nitrogen/generated/ios/swift/HybridFileSinkSpec_cxx.swift +227 -0
- package/nitrogen/generated/ios/swift/HybridNativeConsoleSinkSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridNativeConsoleSinkSpec_cxx.swift +148 -0
- package/nitrogen/generated/ios/swift/RejectReason.swift +48 -0
- package/nitrogen/generated/ios/swift/RotationConfig.swift +93 -0
- package/nitrogen/generated/ios/swift/SinkStatus.swift +44 -0
- package/nitrogen/generated/shared/c++/AppendResult.hpp +105 -0
- package/nitrogen/generated/shared/c++/ClearOutcome.hpp +96 -0
- package/nitrogen/generated/shared/c++/FlushOutcome.hpp +107 -0
- package/nitrogen/generated/shared/c++/HybridFileSinkSpec.cpp +28 -0
- package/nitrogen/generated/shared/c++/HybridFileSinkSpec.hpp +84 -0
- package/nitrogen/generated/shared/c++/HybridNativeConsoleSinkSpec.cpp +22 -0
- package/nitrogen/generated/shared/c++/HybridNativeConsoleSinkSpec.hpp +64 -0
- package/nitrogen/generated/shared/c++/RejectReason.hpp +84 -0
- package/nitrogen/generated/shared/c++/RotationConfig.hpp +103 -0
- package/nitrogen/generated/shared/c++/SinkStatus.hpp +95 -0
- package/package.json +151 -0
- package/src/Logger.ts +417 -0
- package/src/ScopedLogger.ts +94 -0
- package/src/config.ts +25 -0
- package/src/destinations/Batcher.ts +805 -0
- package/src/destinations/ConsoleDestination.ts +60 -0
- package/src/destinations/FileDestination.ts +473 -0
- package/src/destinations/NativeConsoleDestination.ts +220 -0
- package/src/destinations/types.ts +19 -0
- package/src/formatters/DefaultFormatter.ts +150 -0
- package/src/formatters/JsonLinesFormatter.ts +195 -0
- package/src/formatters/timestamp.ts +12 -0
- package/src/formatters/types.ts +29 -0
- package/src/index.tsx +106 -0
- package/src/integrations/appState.ts +111 -0
- package/src/integrations/errorHandler.ts +223 -0
- package/src/integrations/sanitizeError.ts +287 -0
- package/src/levels.ts +35 -0
- package/src/metadata.ts +52 -0
- package/src/privacy.ts +389 -0
- package/src/specs/FileSink.nitro.ts +144 -0
- package/src/specs/NativeConsoleSink.nitro.ts +21 -0
- package/src/types.ts +45 -0
- package/src/utf8.ts +39 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import type { LogEntry, LogLevel } from '../types';
|
|
2
|
+
import type { LogDestination } from './types';
|
|
3
|
+
import type { LogFormatter } from '../formatters/types';
|
|
4
|
+
import { DefaultFormatter } from '../formatters/DefaultFormatter';
|
|
5
|
+
import { LEVEL_ORDER } from '../levels';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The part of `NativeConsoleSink` this destination uses.
|
|
9
|
+
*
|
|
10
|
+
* Structural rather than the Nitro interface itself, so tests can drive it
|
|
11
|
+
* with a double. The real `NativeConsoleSink` satisfies it.
|
|
12
|
+
*/
|
|
13
|
+
export interface NativeConsoleSinkLike {
|
|
14
|
+
install(subsystem: string, category: string): void;
|
|
15
|
+
logBatch(levels: number[], messages: string[]): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface NativeConsoleDestinationOptions {
|
|
19
|
+
/** Registration key. Default 'native-console'. */
|
|
20
|
+
readonly label?: string;
|
|
21
|
+
readonly minimumLevel?: LogLevel;
|
|
22
|
+
/** Default `DefaultFormatter` — os_log wants a line, not a JSON record. */
|
|
23
|
+
readonly formatter?: LogFormatter;
|
|
24
|
+
/** Reverse-DNS, as os_log expects. Default: the bundle's, chosen natively. */
|
|
25
|
+
readonly subsystem?: string;
|
|
26
|
+
/** Default 'log'. Becomes the logcat tag on Android. */
|
|
27
|
+
readonly category?: string;
|
|
28
|
+
/** Entries per bridge crossing. Default 64. */
|
|
29
|
+
readonly batchSize?: number;
|
|
30
|
+
/** Idle coalescing window. Default 100 ms. */
|
|
31
|
+
readonly flushIntervalMs?: number;
|
|
32
|
+
/** Buffer ceiling; oldest survive, newest are dropped. Default 1000. */
|
|
33
|
+
readonly maxPendingEntries?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_BATCH_SIZE = 64;
|
|
37
|
+
const DEFAULT_FLUSH_INTERVAL_MS = 100;
|
|
38
|
+
const DEFAULT_MAX_PENDING = 1000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Number of consecutive failed `logBatch` calls before the destination stops
|
|
42
|
+
* trying. A native sink that is throwing every time will not start working
|
|
43
|
+
* because we asked once more, and a try/catch per drain forever is a cost the
|
|
44
|
+
* app pays for nothing.
|
|
45
|
+
*/
|
|
46
|
+
const FAILURE_LIMIT = 3;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Renders entries into the platform log stream — os_log on iOS, logcat on
|
|
50
|
+
* Android — so JS logs interleave with native ones in Console.app and Xcode.
|
|
51
|
+
*
|
|
52
|
+
* Batched, unlike {@link ConsoleDestination}: one bridge crossing per drain
|
|
53
|
+
* with two parallel primitive arrays, rather than a crossing per entry.
|
|
54
|
+
*
|
|
55
|
+
* Deliberately simpler than {@link FileDestination}. There is no backpressure
|
|
56
|
+
* and no loss accounting because there is nothing to push back on: os_log
|
|
57
|
+
* accepts what it is given and never blocks on a disk. What this destination
|
|
58
|
+
* does account for is its own buffer — entries dropped at the ceiling, and
|
|
59
|
+
* entries lost to a throwing sink — through {@link dropped}, because a
|
|
60
|
+
* diagnostic channel that quietly loses records is worse than one that says
|
|
61
|
+
* it did.
|
|
62
|
+
*
|
|
63
|
+
* The durable copy is the file destination's. Nothing here is a system of
|
|
64
|
+
* record, which is what makes best-effort the right posture.
|
|
65
|
+
*/
|
|
66
|
+
export class NativeConsoleDestination implements LogDestination {
|
|
67
|
+
readonly label: string;
|
|
68
|
+
readonly minimumLevel?: LogLevel;
|
|
69
|
+
|
|
70
|
+
private readonly sink: NativeConsoleSinkLike;
|
|
71
|
+
private readonly formatter: LogFormatter;
|
|
72
|
+
private readonly batchSize: number;
|
|
73
|
+
private readonly flushIntervalMs: number;
|
|
74
|
+
private readonly maxPending: number;
|
|
75
|
+
|
|
76
|
+
private levels: number[] = [];
|
|
77
|
+
private messages: string[] = [];
|
|
78
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
79
|
+
private disposed = false;
|
|
80
|
+
private failures = 0;
|
|
81
|
+
private droppedEntries = 0;
|
|
82
|
+
|
|
83
|
+
constructor(
|
|
84
|
+
sink: NativeConsoleSinkLike,
|
|
85
|
+
options?: NativeConsoleDestinationOptions
|
|
86
|
+
) {
|
|
87
|
+
this.sink = sink;
|
|
88
|
+
this.label = options?.label ?? 'native-console';
|
|
89
|
+
this.minimumLevel = options?.minimumLevel;
|
|
90
|
+
this.formatter = options?.formatter ?? new DefaultFormatter();
|
|
91
|
+
this.batchSize = positive(options?.batchSize, DEFAULT_BATCH_SIZE);
|
|
92
|
+
this.flushIntervalMs = positive(
|
|
93
|
+
options?.flushIntervalMs,
|
|
94
|
+
DEFAULT_FLUSH_INTERVAL_MS
|
|
95
|
+
);
|
|
96
|
+
this.maxPending = positive(options?.maxPendingEntries, DEFAULT_MAX_PENDING);
|
|
97
|
+
|
|
98
|
+
// An empty subsystem is legal and produces a logger nobody can find in
|
|
99
|
+
// Console; the native side substitutes the bundle identifier for it.
|
|
100
|
+
this.sink.install(options?.subsystem ?? '', options?.category ?? 'log');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* False once disposed, or once the native sink has failed enough times in a
|
|
105
|
+
* row to be considered gone. The Logger checks this before formatting, so a
|
|
106
|
+
* dead sink stops costing render work as well as bridge calls.
|
|
107
|
+
*/
|
|
108
|
+
get isEnabled(): boolean {
|
|
109
|
+
return !this.disposed && this.failures < FAILURE_LIMIT;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Entries this destination never handed to the platform log. */
|
|
113
|
+
dropped(): number {
|
|
114
|
+
return this.droppedEntries;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
write(entry: LogEntry): void {
|
|
118
|
+
if (!this.isEnabled) return;
|
|
119
|
+
|
|
120
|
+
if (this.messages.length >= this.maxPending) {
|
|
121
|
+
// Drop-newest, matching the Batcher. Under a burst the oldest entries
|
|
122
|
+
// are the ones that explain how it started, which is what a reader is
|
|
123
|
+
// looking for; keeping the newest would leave only the aftermath.
|
|
124
|
+
this.droppedEntries += 1;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Render first, then append both halves together. The two arrays are
|
|
129
|
+
// parallel and are read by index natively, so pushing the level before the
|
|
130
|
+
// message that might not arrive leaves them one apart — and from that point
|
|
131
|
+
// on every entry in the batch is logged at its predecessor's severity. The
|
|
132
|
+
// Logger catches a throwing destination and carries on, which is exactly
|
|
133
|
+
// what would let the misalignment survive unnoticed.
|
|
134
|
+
let rendered: string;
|
|
135
|
+
try {
|
|
136
|
+
rendered = this.formatter.format(entry);
|
|
137
|
+
} catch {
|
|
138
|
+
// Not counted against the sink's failure run: the sink is fine, the
|
|
139
|
+
// formatter is not, and disabling the channel over someone else's bug
|
|
140
|
+
// would take the console down for every entry a working formatter could
|
|
141
|
+
// still have rendered.
|
|
142
|
+
this.droppedEntries += 1;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this.levels.push(LEVEL_ORDER[entry.level]);
|
|
147
|
+
this.messages.push(rendered);
|
|
148
|
+
|
|
149
|
+
if (this.messages.length >= this.batchSize) {
|
|
150
|
+
this.drain();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
this.arm();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Hands everything buffered to the platform log.
|
|
158
|
+
*
|
|
159
|
+
* The deadline is accepted and ignored, deliberately: `logBatch` does not
|
|
160
|
+
* wait on anything, so there is no wait to bound. Taking the parameter keeps
|
|
161
|
+
* the destination interchangeable with the ones that do need it.
|
|
162
|
+
*/
|
|
163
|
+
flush(_deadlineMs?: number): void {
|
|
164
|
+
this.drain();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
dispose(): void {
|
|
168
|
+
if (this.disposed) return;
|
|
169
|
+
// Flush before the flag, not after: entries buffered up to this moment
|
|
170
|
+
// were accepted, and disposing is not a reason to drop them.
|
|
171
|
+
this.drain();
|
|
172
|
+
this.disposed = true;
|
|
173
|
+
this.cancel();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private drain(): void {
|
|
177
|
+
this.cancel();
|
|
178
|
+
if (this.messages.length === 0) return;
|
|
179
|
+
|
|
180
|
+
const levels = this.levels;
|
|
181
|
+
const messages = this.messages;
|
|
182
|
+
this.levels = [];
|
|
183
|
+
this.messages = [];
|
|
184
|
+
|
|
185
|
+
if (this.disposed) return;
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
this.sink.logBatch(levels, messages);
|
|
189
|
+
this.failures = 0;
|
|
190
|
+
} catch {
|
|
191
|
+
// Payload-free: whatever the native side threw is not worth risking on
|
|
192
|
+
// the way back through a logger. The batch is gone either way, so it is
|
|
193
|
+
// counted rather than retried — replaying it against a sink that just
|
|
194
|
+
// rejected it only doubles the chance of the same failure.
|
|
195
|
+
this.failures += 1;
|
|
196
|
+
this.droppedEntries += messages.length;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private arm(): void {
|
|
201
|
+
if (this.timer !== undefined) return;
|
|
202
|
+
this.timer = setTimeout(() => {
|
|
203
|
+
this.timer = undefined;
|
|
204
|
+
this.drain();
|
|
205
|
+
}, this.flushIntervalMs);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private cancel(): void {
|
|
209
|
+
if (this.timer === undefined) return;
|
|
210
|
+
clearTimeout(this.timer);
|
|
211
|
+
this.timer = undefined;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Finite positive integers only; anything else falls back. */
|
|
216
|
+
function positive(value: number | undefined, fallback: number): number {
|
|
217
|
+
if (value === undefined) return fallback;
|
|
218
|
+
if (!Number.isSafeInteger(value) || value < 1) return fallback;
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { LogEntry, LogLevel } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A log output backend. The Logger filters on `isEnabled` and `minimumLevel`
|
|
5
|
+
* BEFORE evaluating a lazy message, isolates each destination's `write` (one
|
|
6
|
+
* throwing destination never blocks siblings), and calls `dispose()` on
|
|
7
|
+
* replace/remove/reset — JS garbage collection is nondeterministic, so
|
|
8
|
+
* timers, native handles, and listeners must be released explicitly.
|
|
9
|
+
*/
|
|
10
|
+
export interface LogDestination {
|
|
11
|
+
readonly label: string;
|
|
12
|
+
readonly minimumLevel?: LogLevel;
|
|
13
|
+
readonly isEnabled: boolean;
|
|
14
|
+
write(entry: LogEntry): void;
|
|
15
|
+
/** Drain any buffered output synchronously (bounded by deadlineMs). */
|
|
16
|
+
flush(deadlineMs?: number): void;
|
|
17
|
+
/** Idempotent: cancel timers, flush, release native handles/listeners. */
|
|
18
|
+
dispose(): void;
|
|
19
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { LogEntry } from '../types';
|
|
2
|
+
import type { LogFormatter } from './types';
|
|
3
|
+
import { LEVEL_TAG } from '../levels';
|
|
4
|
+
import { formatTime } from './timestamp';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SwiftLogger's default layout minus the `File.swift:42` column (no call-site
|
|
8
|
+
* capture in JS):
|
|
9
|
+
*
|
|
10
|
+
* LEVEL | HH:mm:ss.SSS | [correlation] [subsystem] message {key=value}
|
|
11
|
+
*
|
|
12
|
+
* Metadata renders as `key=value` pairs sorted by key.
|
|
13
|
+
*
|
|
14
|
+
* A multi-line message keeps its lines, indented under the message column:
|
|
15
|
+
*
|
|
16
|
+
* ERROR | 12:15:30.842 | Unhandled TypeError
|
|
17
|
+
* | | at foo (bundle.js:1:2)
|
|
18
|
+
*
|
|
19
|
+
* See {@link CONTINUATION} for why that indent is load-bearing rather than
|
|
20
|
+
* decorative.
|
|
21
|
+
*/
|
|
22
|
+
export class DefaultFormatter implements LogFormatter {
|
|
23
|
+
format(entry: LogEntry): string {
|
|
24
|
+
let tags = '';
|
|
25
|
+
if (entry.correlation !== undefined) {
|
|
26
|
+
tags += `[${escapeControls(entry.correlation)}] `;
|
|
27
|
+
}
|
|
28
|
+
if (entry.subsystem !== undefined) {
|
|
29
|
+
tags += `[${escapeControls(entry.subsystem)}] `;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let body = `${tags}${formatMessage(entry.message)}`;
|
|
33
|
+
if (entry.metadata) {
|
|
34
|
+
const metadata = entry.metadata;
|
|
35
|
+
const keys = Object.keys(metadata).sort();
|
|
36
|
+
if (keys.length > 0) {
|
|
37
|
+
const pairs = keys
|
|
38
|
+
.map(
|
|
39
|
+
(k) => `${escapeControls(k)}=${escapeControls(String(metadata[k]))}`
|
|
40
|
+
)
|
|
41
|
+
.join(', ');
|
|
42
|
+
body += ` {${pairs}}`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return `${LEVEL_TAG[entry.level]} | ${formatTime(entry.timestamp)} | ${body}`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Blanks the tag and timestamp columns, so a continuation line lines up under
|
|
52
|
+
* the message and cannot be read as a record of its own.
|
|
53
|
+
*
|
|
54
|
+
* ERROR | 12:15:30.842 | Unhandled TypeError
|
|
55
|
+
* | | at foo (bundle.js:1:2)
|
|
56
|
+
*
|
|
57
|
+
* Five spaces for the level tag, twelve for `HH:mm:ss.SSS`, and the two
|
|
58
|
+
* separators. A real record always carries a known tag and a timestamp made
|
|
59
|
+
* of digits, so a blank in either column cannot be mistaken for one.
|
|
60
|
+
*/
|
|
61
|
+
const CONTINUATION = `${' '.repeat(5)} | ${' '.repeat(12)} | `;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Renders the message, keeping its lines but never letting one start a record.
|
|
65
|
+
*
|
|
66
|
+
* SwiftLogger leaves `message` completely alone, and this package did too
|
|
67
|
+
* until review pointed out that matching the reference is not the same as
|
|
68
|
+
* being safe. The forgery is the one escaping the structured fields prevents:
|
|
69
|
+
* an error message or an interpolated string carrying
|
|
70
|
+
* `"\nERROR | 00:00:00.000 | patient discharged"` writes a line a reader
|
|
71
|
+
* cannot tell from a real one. Reachability is not theoretical —
|
|
72
|
+
* `no-dynamic-message` is what normally keeps arbitrary text out of this
|
|
73
|
+
* field, and it cannot see a logger wired through a function call.
|
|
74
|
+
*
|
|
75
|
+
* Escaping newlines outright would fix it and ruin the crash handler, which
|
|
76
|
+
* logs stack traces through this field on purpose. Indenting continuation
|
|
77
|
+
* lines keeps them readable and still leaves nothing that parses as a header.
|
|
78
|
+
*
|
|
79
|
+
* This is a deliberate parity difference; docs/PARITY.md records it.
|
|
80
|
+
*/
|
|
81
|
+
function formatMessage(message: string): string {
|
|
82
|
+
// Four things besides \n break a line. A bare \r does, and can also drag
|
|
83
|
+
// the cursor back over what was already printed. U+0085 (NEL) does for any
|
|
84
|
+
// Unicode-aware reader. U+2028 and U+2029 do for anything treating this as
|
|
85
|
+
// JavaScript-flavoured text.
|
|
86
|
+
return message
|
|
87
|
+
.split(/\r\n|[\n\r\u0085\u2028\u2029]/)
|
|
88
|
+
.map(escapeControls)
|
|
89
|
+
.join(`\n${CONTINUATION}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Renders C0, DEL, C1, and the Unicode line/paragraph separators as escapes.
|
|
94
|
+
*
|
|
95
|
+
* C1 (U+0080–U+009F) matters as much as C0 here and is easy to forget:
|
|
96
|
+
* U+009B is a single-character CSI, so a terminal will happily read what
|
|
97
|
+
* follows it as a cursor-movement or erase sequence, and U+0085 is NEL,
|
|
98
|
+
* which Unicode-aware readers treat as a line break.
|
|
99
|
+
*
|
|
100
|
+
* U+2028 and U+2029 are here for exactly the reason U+0085 is. They are not
|
|
101
|
+
* control characters and a plain `\n`-splitting reader ignores them, but a
|
|
102
|
+
* JavaScript one does not: `^` and `$` under the `m` flag treat both as line
|
|
103
|
+
* terminators, so a log viewer written in JS — the likely kind for this
|
|
104
|
+
* package — sees a line break where a terminal sees none. That asymmetry is
|
|
105
|
+
* worse than either alone, because the forged line is invisible in the tool
|
|
106
|
+
* someone checks the raw file with.
|
|
107
|
+
*
|
|
108
|
+
* This layout is one entry per line, so a newline inside a *structured* field
|
|
109
|
+
* lets whoever supplied that field forge whole log entries. A correlation ID,
|
|
110
|
+
* a subsystem, or a metadata key or value can all arrive from a request
|
|
111
|
+
* header, a username, or a URL; without this, `"a\nERROR | 00:00:00.000 | "`
|
|
112
|
+
* writes a convincing fake line into the log, and the reader has no way to
|
|
113
|
+
* tell it from a real one.
|
|
114
|
+
*
|
|
115
|
+
* The message takes a different route — see {@link formatMessage} — because
|
|
116
|
+
* it has to stay multi-line. Everything else is escaped outright.
|
|
117
|
+
*
|
|
118
|
+
* Clean fields — essentially all of them — are scanned once and returned
|
|
119
|
+
* whole, so this costs nothing in the normal case.
|
|
120
|
+
*/
|
|
121
|
+
function escapeControls(field: string): string {
|
|
122
|
+
// eslint-disable-next-line no-control-regex
|
|
123
|
+
if (!/[\u0000-\u001F\u007F-\u009F\u2028\u2029]/.test(field)) return field;
|
|
124
|
+
|
|
125
|
+
let out = '';
|
|
126
|
+
for (const character of field) {
|
|
127
|
+
switch (character) {
|
|
128
|
+
case '\n':
|
|
129
|
+
out += '\\n';
|
|
130
|
+
break;
|
|
131
|
+
case '\r':
|
|
132
|
+
out += '\\r';
|
|
133
|
+
break;
|
|
134
|
+
case '\t':
|
|
135
|
+
out += '\\t';
|
|
136
|
+
break;
|
|
137
|
+
default: {
|
|
138
|
+
const code = character.codePointAt(0)!;
|
|
139
|
+
out +=
|
|
140
|
+
code < 0x20 ||
|
|
141
|
+
(code >= 0x7f && code <= 0x9f) ||
|
|
142
|
+
code === 0x2028 ||
|
|
143
|
+
code === 0x2029
|
|
144
|
+
? `\\u{${code.toString(16).toUpperCase()}}`
|
|
145
|
+
: character;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { LogEntry, LogPrimitive, RedactedMetadata } from '../types';
|
|
2
|
+
import type { LogFormatter } from './types';
|
|
3
|
+
import { LEVEL_NAME } from '../levels';
|
|
4
|
+
import { utf8Length } from '../utf8';
|
|
5
|
+
|
|
6
|
+
export { utf8Length };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How the `timestamp` field renders.
|
|
10
|
+
*
|
|
11
|
+
* `iso8601` is UTC with milliseconds — `2026-07-27T12:15:30.842Z` — and is
|
|
12
|
+
* what the SwiftLogger goldens are written against. `epochSeconds` emits a
|
|
13
|
+
* JSON number of seconds, for aggregators that would only parse the string
|
|
14
|
+
* back again.
|
|
15
|
+
*/
|
|
16
|
+
export type JsonTimestampStyle = 'iso8601' | 'epochSeconds';
|
|
17
|
+
|
|
18
|
+
export interface JsonLinesFormatterOptions {
|
|
19
|
+
readonly timestampStyle?: JsonTimestampStyle;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Instants JavaScript can represent as a Date but not as ISO 8601. */
|
|
23
|
+
const MAX_ISO_MS = 8.64e15;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One JSON object per line, matching SwiftLogger's `JSONLogFormatter`.
|
|
27
|
+
*
|
|
28
|
+
* Keys come out in a fixed order — `timestamp`, `level`, `message`, then the
|
|
29
|
+
* optional `correlation`, `subsystem`, and `metadata` — so that output diffs
|
|
30
|
+
* cleanly and goldens are stable. Absent optionals are omitted rather than
|
|
31
|
+
* written as null, and metadata values keep their JSON type.
|
|
32
|
+
*
|
|
33
|
+
* Metadata keys are sorted, which is also why the object is assembled by hand
|
|
34
|
+
* rather than handed to `JSON.stringify`. JavaScript orders integer-like
|
|
35
|
+
* string keys before all others, so `JSON.stringify({ b: 1, '2': 2 })` puts
|
|
36
|
+
* `2` first no matter what order the keys went in. A metadata key is whatever
|
|
37
|
+
* the caller wrote, `'2'` included, and the Swift side sorts every key the
|
|
38
|
+
* same way.
|
|
39
|
+
*
|
|
40
|
+
* See `docs/PARITY.md` for the field-by-field comparison, including the three
|
|
41
|
+
* fields this formatter deliberately does not emit.
|
|
42
|
+
*/
|
|
43
|
+
export class JsonLinesFormatter implements LogFormatter {
|
|
44
|
+
/**
|
|
45
|
+
* Every control character is escaped, so a record cannot contain a raw
|
|
46
|
+
* newline and native crash-tail trimming can find record boundaries.
|
|
47
|
+
*/
|
|
48
|
+
readonly framing = 'line' as const;
|
|
49
|
+
|
|
50
|
+
private readonly timestampStyle: JsonTimestampStyle;
|
|
51
|
+
|
|
52
|
+
constructor(options: JsonLinesFormatterOptions = {}) {
|
|
53
|
+
this.timestampStyle = options.timestampStyle ?? 'iso8601';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
format(entry: LogEntry): string {
|
|
57
|
+
return this.render(entry, entry.message, entry.metadata, false);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Render within a byte budget, shedding content in a fixed order rather
|
|
62
|
+
* than slicing the finished string.
|
|
63
|
+
*
|
|
64
|
+
* Cutting a JSON record to length produces something no parser will accept,
|
|
65
|
+
* and a log line that cannot be parsed is worth less than one that is
|
|
66
|
+
* honestly incomplete. So: drop metadata from the end of sorted key order,
|
|
67
|
+
* then shorten the message, and mark the result `"truncated":true` either
|
|
68
|
+
* way. A reader can tell the difference between a short message and a
|
|
69
|
+
* shortened one.
|
|
70
|
+
*
|
|
71
|
+
* The budget counts UTF-8 bytes, because that is what the sink reserves.
|
|
72
|
+
* An unpaired surrogate never distorts the count here: `JSON.stringify` has
|
|
73
|
+
* already turned it into a six-character `\udXXX` escape by the time
|
|
74
|
+
* anything is measured.
|
|
75
|
+
*
|
|
76
|
+
* When even an empty message will not fit, the result comes back OVER
|
|
77
|
+
* budget: it is the smallest record that still identifies the entry, which
|
|
78
|
+
* is not quite the same as the smallest record possible. `correlation` and
|
|
79
|
+
* `subsystem` survive, because an entry no one can place is worth less than
|
|
80
|
+
* a short one — dropping them would save bytes and leave a line that says
|
|
81
|
+
* nothing about where it came from. Callers deciding whether an entry is
|
|
82
|
+
* renderable at all must therefore measure the result rather than assume it
|
|
83
|
+
* fits.
|
|
84
|
+
*/
|
|
85
|
+
formatWithin(entry: LogEntry, maxBytes: number): string {
|
|
86
|
+
const full = this.format(entry);
|
|
87
|
+
if (utf8Length(full) <= maxBytes) return full;
|
|
88
|
+
|
|
89
|
+
// Metadata first: a dropped field costs the reader one fact, whereas a
|
|
90
|
+
// shortened message can cost them the sentence that explains the others.
|
|
91
|
+
const keys = entry.metadata ? Object.keys(entry.metadata).sort() : [];
|
|
92
|
+
for (let keep = keys.length - 1; keep >= 0; keep -= 1) {
|
|
93
|
+
const subset: Record<string, LogPrimitive> = {};
|
|
94
|
+
for (const key of keys.slice(0, keep)) {
|
|
95
|
+
subset[key] = entry.metadata![key]!;
|
|
96
|
+
}
|
|
97
|
+
const candidate = this.render(
|
|
98
|
+
entry,
|
|
99
|
+
entry.message,
|
|
100
|
+
keep === 0 ? undefined : subset,
|
|
101
|
+
true
|
|
102
|
+
);
|
|
103
|
+
if (utf8Length(candidate) <= maxBytes) return candidate;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Then the message, by code points so a surrogate pair is never split
|
|
107
|
+
// into a lone half. Escaping means a code point can cost anywhere from
|
|
108
|
+
// one to twelve bytes, so the fit is found by measuring whole rendered
|
|
109
|
+
// records rather than by arithmetic on the message alone.
|
|
110
|
+
const points = Array.from(entry.message);
|
|
111
|
+
let low = 0;
|
|
112
|
+
let high = points.length;
|
|
113
|
+
while (low < high) {
|
|
114
|
+
const mid = Math.ceil((low + high) / 2);
|
|
115
|
+
const candidate = this.render(
|
|
116
|
+
entry,
|
|
117
|
+
points.slice(0, mid).join(''),
|
|
118
|
+
undefined,
|
|
119
|
+
true
|
|
120
|
+
);
|
|
121
|
+
if (utf8Length(candidate) <= maxBytes) low = mid;
|
|
122
|
+
else high = mid - 1;
|
|
123
|
+
}
|
|
124
|
+
return this.render(entry, points.slice(0, low).join(''), undefined, true);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private render(
|
|
128
|
+
entry: LogEntry,
|
|
129
|
+
message: string,
|
|
130
|
+
metadata: RedactedMetadata | undefined,
|
|
131
|
+
truncated: boolean
|
|
132
|
+
): string {
|
|
133
|
+
let json = '{"timestamp":';
|
|
134
|
+
json += this.renderTimestamp(entry.timestamp);
|
|
135
|
+
json += ',"level":' + JSON.stringify(LEVEL_NAME[entry.level]);
|
|
136
|
+
json += ',"message":' + JSON.stringify(message);
|
|
137
|
+
|
|
138
|
+
if (entry.correlation !== undefined) {
|
|
139
|
+
json += ',"correlation":' + JSON.stringify(entry.correlation);
|
|
140
|
+
}
|
|
141
|
+
if (entry.subsystem !== undefined) {
|
|
142
|
+
json += ',"subsystem":' + JSON.stringify(entry.subsystem);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (metadata) {
|
|
146
|
+
const keys = Object.keys(metadata).sort();
|
|
147
|
+
if (keys.length > 0) {
|
|
148
|
+
json += ',"metadata":{';
|
|
149
|
+
for (let i = 0; i < keys.length; i += 1) {
|
|
150
|
+
if (i > 0) json += ',';
|
|
151
|
+
json +=
|
|
152
|
+
JSON.stringify(keys[i]) + ':' + renderValue(metadata[keys[i]!]!);
|
|
153
|
+
}
|
|
154
|
+
json += '}';
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (truncated) json += ',"truncated":true';
|
|
159
|
+
return json + '}';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private renderTimestamp(epochMs: number): string {
|
|
163
|
+
if (this.timestampStyle === 'epochSeconds') {
|
|
164
|
+
return Number.isFinite(epochMs) ? String(epochMs / 1000) : '0';
|
|
165
|
+
}
|
|
166
|
+
// `toISOString` throws past the representable range, and a formatter that
|
|
167
|
+
// throws takes down the log call that was trying to report a problem.
|
|
168
|
+
if (!Number.isFinite(epochMs) || Math.abs(epochMs) > MAX_ISO_MS) {
|
|
169
|
+
return '"1970-01-01T00:00:00.000Z"';
|
|
170
|
+
}
|
|
171
|
+
return JSON.stringify(new Date(epochMs).toISOString());
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A metadata value as JSON, keeping its type.
|
|
177
|
+
*
|
|
178
|
+
* Non-finite numbers become quoted strings because JSON has no literal for
|
|
179
|
+
* them, matching what the Swift formatter does. Redaction rejects them long
|
|
180
|
+
* before this point, so the branch exists to keep a hand-built entry from
|
|
181
|
+
* producing `NaN` in the middle of a record rather than because it is
|
|
182
|
+
* reachable through the public API.
|
|
183
|
+
*/
|
|
184
|
+
function renderValue(value: LogPrimitive): string {
|
|
185
|
+
switch (typeof value) {
|
|
186
|
+
case 'string':
|
|
187
|
+
return JSON.stringify(value);
|
|
188
|
+
case 'boolean':
|
|
189
|
+
return value ? 'true' : 'false';
|
|
190
|
+
default:
|
|
191
|
+
return Number.isFinite(value)
|
|
192
|
+
? String(value)
|
|
193
|
+
: JSON.stringify(String(value));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Local-time `HH:mm:ss.SSS`, matching SwiftLogger's console timestamp.
|
|
2
|
+
* Takes epoch milliseconds (see `LogEntry.timestamp`); plain Date getters +
|
|
3
|
+
* zero-pad — none of the Swift version's caching is needed, these are cheap
|
|
4
|
+
* in JS. */
|
|
5
|
+
export function formatTime(epochMs: number): string {
|
|
6
|
+
const date = new Date(epochMs);
|
|
7
|
+
const hh = String(date.getHours()).padStart(2, '0');
|
|
8
|
+
const mm = String(date.getMinutes()).padStart(2, '0');
|
|
9
|
+
const ss = String(date.getSeconds()).padStart(2, '0');
|
|
10
|
+
const ms = String(date.getMilliseconds()).padStart(3, '0');
|
|
11
|
+
return `${hh}:${mm}:${ss}.${ms}`;
|
|
12
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { LogEntry } from '../types';
|
|
2
|
+
|
|
3
|
+
export interface LogFormatter {
|
|
4
|
+
/**
|
|
5
|
+
* Framing contract for file destinations: 'line' guarantees exactly one
|
|
6
|
+
* escaped record per line (newlines in content are escaped), which is what
|
|
7
|
+
* makes native crash-tail trimming safe. Formatters that cannot guarantee
|
|
8
|
+
* this omit it and are documented as having reduced crash-tail recovery.
|
|
9
|
+
*/
|
|
10
|
+
readonly framing?: 'line';
|
|
11
|
+
|
|
12
|
+
format(entry: LogEntry): string;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Render `entry` within a UTF-8 byte budget by shedding content
|
|
16
|
+
* structurally — dropping whole fields, truncating a field at code-point
|
|
17
|
+
* boundaries — so the result is still well-formed in this format.
|
|
18
|
+
*
|
|
19
|
+
* A formatter that omits this is never sliced to fit: an entry too large
|
|
20
|
+
* for the destination's per-entry limit is replaced whole by a fixed
|
|
21
|
+
* notice, because cutting a rendered record to length is how a log file
|
|
22
|
+
* stops being parseable.
|
|
23
|
+
*
|
|
24
|
+
* Best effort. Returning something still over budget is allowed — a record
|
|
25
|
+
* has a floor below which it no longer identifies anything — and callers
|
|
26
|
+
* must measure the result rather than assume it fits.
|
|
27
|
+
*/
|
|
28
|
+
formatWithin?(entry: LogEntry, maxBytes: number): string;
|
|
29
|
+
}
|