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.
Files changed (205) hide show
  1. package/LICENSE +20 -0
  2. package/NitroLogger.podspec +29 -0
  3. package/README.md +214 -0
  4. package/android/CMakeLists.txt +24 -0
  5. package/android/build.gradle +124 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/cpp/cpp-adapter.cpp +11 -0
  8. package/android/src/main/java/com/margelo/nitro/nitrologger/AndroidPlatformIo.kt +100 -0
  9. package/android/src/main/java/com/margelo/nitro/nitrologger/HybridFileSink.kt +292 -0
  10. package/android/src/main/java/com/margelo/nitro/nitrologger/HybridNativeConsoleSink.kt +23 -0
  11. package/android/src/main/java/com/margelo/nitro/nitrologger/LogFileWriter.kt +1328 -0
  12. package/android/src/main/java/com/margelo/nitro/nitrologger/LogRotationPolicy.kt +186 -0
  13. package/android/src/main/java/com/margelo/nitro/nitrologger/LogSecureFile.kt +80 -0
  14. package/android/src/main/java/com/margelo/nitro/nitrologger/LogWriterRegistry.kt +420 -0
  15. package/android/src/main/java/com/margelo/nitro/nitrologger/NativeConsoleWriter.kt +237 -0
  16. package/android/src/main/java/com/margelo/nitro/nitrologger/NitroLoggerPackage.kt +22 -0
  17. package/android/src/main/java/com/margelo/nitro/nitrologger/PlatformIo.kt +165 -0
  18. package/docs/PARITY.md +330 -0
  19. package/docs/PRIVACY.md +341 -0
  20. package/eslint-plugin/README.md +238 -0
  21. package/eslint-plugin/index.js +62 -0
  22. package/eslint-plugin/rules/literal-subsystem.js +102 -0
  23. package/eslint-plugin/rules/no-computed-metadata-key.js +174 -0
  24. package/eslint-plugin/rules/no-derived-correlation.js +171 -0
  25. package/eslint-plugin/rules/no-dynamic-message.js +60 -0
  26. package/eslint-plugin/shared.js +1948 -0
  27. package/ios/Gzip.swift +203 -0
  28. package/ios/HybridFileSink.swift +291 -0
  29. package/ios/HybridNativeConsoleSink.swift +20 -0
  30. package/ios/LogFileWriter.swift +1280 -0
  31. package/ios/LogSecureFile.swift +296 -0
  32. package/ios/LogWriterRegistry.swift +444 -0
  33. package/ios/NativeConsoleWriter.swift +180 -0
  34. package/lib/module/Logger.js +381 -0
  35. package/lib/module/Logger.js.map +1 -0
  36. package/lib/module/ScopedLogger.js +70 -0
  37. package/lib/module/ScopedLogger.js.map +1 -0
  38. package/lib/module/config.js +23 -0
  39. package/lib/module/config.js.map +1 -0
  40. package/lib/module/destinations/Batcher.js +647 -0
  41. package/lib/module/destinations/Batcher.js.map +1 -0
  42. package/lib/module/destinations/ConsoleDestination.js +51 -0
  43. package/lib/module/destinations/ConsoleDestination.js.map +1 -0
  44. package/lib/module/destinations/FileDestination.js +360 -0
  45. package/lib/module/destinations/FileDestination.js.map +1 -0
  46. package/lib/module/destinations/NativeConsoleDestination.js +170 -0
  47. package/lib/module/destinations/NativeConsoleDestination.js.map +1 -0
  48. package/lib/module/destinations/types.js +4 -0
  49. package/lib/module/destinations/types.js.map +1 -0
  50. package/lib/module/formatters/DefaultFormatter.js +136 -0
  51. package/lib/module/formatters/DefaultFormatter.js.map +1 -0
  52. package/lib/module/formatters/JsonLinesFormatter.js +162 -0
  53. package/lib/module/formatters/JsonLinesFormatter.js.map +1 -0
  54. package/lib/module/formatters/timestamp.js +15 -0
  55. package/lib/module/formatters/timestamp.js.map +1 -0
  56. package/lib/module/formatters/types.js +4 -0
  57. package/lib/module/formatters/types.js.map +1 -0
  58. package/lib/module/index.js +31 -0
  59. package/lib/module/index.js.map +1 -0
  60. package/lib/module/integrations/appState.js +81 -0
  61. package/lib/module/integrations/appState.js.map +1 -0
  62. package/lib/module/integrations/errorHandler.js +166 -0
  63. package/lib/module/integrations/errorHandler.js.map +1 -0
  64. package/lib/module/integrations/sanitizeError.js +238 -0
  65. package/lib/module/integrations/sanitizeError.js.map +1 -0
  66. package/lib/module/levels.js +35 -0
  67. package/lib/module/levels.js.map +1 -0
  68. package/lib/module/metadata.js +52 -0
  69. package/lib/module/metadata.js.map +1 -0
  70. package/lib/module/package.json +1 -0
  71. package/lib/module/privacy.js +349 -0
  72. package/lib/module/privacy.js.map +1 -0
  73. package/lib/module/specs/FileSink.nitro.js +4 -0
  74. package/lib/module/specs/FileSink.nitro.js.map +1 -0
  75. package/lib/module/specs/NativeConsoleSink.nitro.js +4 -0
  76. package/lib/module/specs/NativeConsoleSink.nitro.js.map +1 -0
  77. package/lib/module/types.js +4 -0
  78. package/lib/module/types.js.map +1 -0
  79. package/lib/module/utf8.js +42 -0
  80. package/lib/module/utf8.js.map +1 -0
  81. package/lib/typescript/package.json +1 -0
  82. package/lib/typescript/src/Logger.d.ts +122 -0
  83. package/lib/typescript/src/Logger.d.ts.map +1 -0
  84. package/lib/typescript/src/ScopedLogger.d.ts +36 -0
  85. package/lib/typescript/src/ScopedLogger.d.ts.map +1 -0
  86. package/lib/typescript/src/config.d.ts +12 -0
  87. package/lib/typescript/src/config.d.ts.map +1 -0
  88. package/lib/typescript/src/destinations/Batcher.d.ts +244 -0
  89. package/lib/typescript/src/destinations/Batcher.d.ts.map +1 -0
  90. package/lib/typescript/src/destinations/ConsoleDestination.d.ts +27 -0
  91. package/lib/typescript/src/destinations/ConsoleDestination.d.ts.map +1 -0
  92. package/lib/typescript/src/destinations/FileDestination.d.ts +200 -0
  93. package/lib/typescript/src/destinations/FileDestination.d.ts.map +1 -0
  94. package/lib/typescript/src/destinations/NativeConsoleDestination.d.ts +86 -0
  95. package/lib/typescript/src/destinations/NativeConsoleDestination.d.ts.map +1 -0
  96. package/lib/typescript/src/destinations/types.d.ts +19 -0
  97. package/lib/typescript/src/destinations/types.d.ts.map +1 -0
  98. package/lib/typescript/src/formatters/DefaultFormatter.d.ts +22 -0
  99. package/lib/typescript/src/formatters/DefaultFormatter.d.ts.map +1 -0
  100. package/lib/typescript/src/formatters/JsonLinesFormatter.d.ts +73 -0
  101. package/lib/typescript/src/formatters/JsonLinesFormatter.d.ts.map +1 -0
  102. package/lib/typescript/src/formatters/timestamp.d.ts +6 -0
  103. package/lib/typescript/src/formatters/timestamp.d.ts.map +1 -0
  104. package/lib/typescript/src/formatters/types.d.ts +27 -0
  105. package/lib/typescript/src/formatters/types.d.ts.map +1 -0
  106. package/lib/typescript/src/index.d.ts +34 -0
  107. package/lib/typescript/src/index.d.ts.map +1 -0
  108. package/lib/typescript/src/integrations/appState.d.ts +33 -0
  109. package/lib/typescript/src/integrations/appState.d.ts.map +1 -0
  110. package/lib/typescript/src/integrations/errorHandler.d.ts +55 -0
  111. package/lib/typescript/src/integrations/errorHandler.d.ts.map +1 -0
  112. package/lib/typescript/src/integrations/sanitizeError.d.ts +89 -0
  113. package/lib/typescript/src/integrations/sanitizeError.d.ts.map +1 -0
  114. package/lib/typescript/src/levels.d.ts +9 -0
  115. package/lib/typescript/src/levels.d.ts.map +1 -0
  116. package/lib/typescript/src/metadata.d.ts +23 -0
  117. package/lib/typescript/src/metadata.d.ts.map +1 -0
  118. package/lib/typescript/src/privacy.d.ts +103 -0
  119. package/lib/typescript/src/privacy.d.ts.map +1 -0
  120. package/lib/typescript/src/specs/FileSink.nitro.d.ts +131 -0
  121. package/lib/typescript/src/specs/FileSink.nitro.d.ts.map +1 -0
  122. package/lib/typescript/src/specs/NativeConsoleSink.nitro.d.ts +21 -0
  123. package/lib/typescript/src/specs/NativeConsoleSink.nitro.d.ts.map +1 -0
  124. package/lib/typescript/src/types.d.ts +37 -0
  125. package/lib/typescript/src/types.d.ts.map +1 -0
  126. package/lib/typescript/src/utf8.d.ts +15 -0
  127. package/lib/typescript/src/utf8.d.ts.map +1 -0
  128. package/nitro.json +33 -0
  129. package/nitrogen/generated/android/c++/JAppendResult.hpp +79 -0
  130. package/nitrogen/generated/android/c++/JClearOutcome.hpp +88 -0
  131. package/nitrogen/generated/android/c++/JFlushOutcome.hpp +81 -0
  132. package/nitrogen/generated/android/c++/JHybridFileSinkSpec.cpp +120 -0
  133. package/nitrogen/generated/android/c++/JHybridFileSinkSpec.hpp +69 -0
  134. package/nitrogen/generated/android/c++/JHybridNativeConsoleSinkSpec.cpp +71 -0
  135. package/nitrogen/generated/android/c++/JHybridNativeConsoleSinkSpec.hpp +64 -0
  136. package/nitrogen/generated/android/c++/JRejectReason.hpp +64 -0
  137. package/nitrogen/generated/android/c++/JRotationConfig.hpp +77 -0
  138. package/nitrogen/generated/android/c++/JSinkStatus.hpp +69 -0
  139. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/AppendResult.kt +76 -0
  140. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/ClearOutcome.kt +66 -0
  141. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/FlushOutcome.kt +81 -0
  142. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/HybridFileSinkSpec.kt +80 -0
  143. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/HybridNativeConsoleSinkSpec.kt +58 -0
  144. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/RejectReason.kt +25 -0
  145. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/RotationConfig.kt +76 -0
  146. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/SinkStatus.kt +66 -0
  147. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/nitrologgerOnLoad.kt +35 -0
  148. package/nitrogen/generated/android/nitrologger+autolinking.cmake +83 -0
  149. package/nitrogen/generated/android/nitrologger+autolinking.gradle +27 -0
  150. package/nitrogen/generated/android/nitrologgerOnLoad.cpp +70 -0
  151. package/nitrogen/generated/android/nitrologgerOnLoad.hpp +34 -0
  152. package/nitrogen/generated/ios/NitroLogger+autolinking.rb +62 -0
  153. package/nitrogen/generated/ios/NitroLogger-Swift-Cxx-Bridge.cpp +50 -0
  154. package/nitrogen/generated/ios/NitroLogger-Swift-Cxx-Bridge.hpp +216 -0
  155. package/nitrogen/generated/ios/NitroLogger-Swift-Cxx-Umbrella.hpp +69 -0
  156. package/nitrogen/generated/ios/NitroLoggerAutolinking.mm +41 -0
  157. package/nitrogen/generated/ios/NitroLoggerAutolinking.swift +38 -0
  158. package/nitrogen/generated/ios/c++/HybridFileSinkSpecSwift.cpp +11 -0
  159. package/nitrogen/generated/ios/c++/HybridFileSinkSpecSwift.hpp +150 -0
  160. package/nitrogen/generated/ios/c++/HybridNativeConsoleSinkSpecSwift.cpp +11 -0
  161. package/nitrogen/generated/ios/c++/HybridNativeConsoleSinkSpecSwift.hpp +87 -0
  162. package/nitrogen/generated/ios/swift/AppendResult.swift +60 -0
  163. package/nitrogen/generated/ios/swift/ClearOutcome.swift +50 -0
  164. package/nitrogen/generated/ios/swift/FlushOutcome.swift +59 -0
  165. package/nitrogen/generated/ios/swift/HybridFileSinkSpec.swift +61 -0
  166. package/nitrogen/generated/ios/swift/HybridFileSinkSpec_cxx.swift +227 -0
  167. package/nitrogen/generated/ios/swift/HybridNativeConsoleSinkSpec.swift +56 -0
  168. package/nitrogen/generated/ios/swift/HybridNativeConsoleSinkSpec_cxx.swift +148 -0
  169. package/nitrogen/generated/ios/swift/RejectReason.swift +48 -0
  170. package/nitrogen/generated/ios/swift/RotationConfig.swift +93 -0
  171. package/nitrogen/generated/ios/swift/SinkStatus.swift +44 -0
  172. package/nitrogen/generated/shared/c++/AppendResult.hpp +105 -0
  173. package/nitrogen/generated/shared/c++/ClearOutcome.hpp +96 -0
  174. package/nitrogen/generated/shared/c++/FlushOutcome.hpp +107 -0
  175. package/nitrogen/generated/shared/c++/HybridFileSinkSpec.cpp +28 -0
  176. package/nitrogen/generated/shared/c++/HybridFileSinkSpec.hpp +84 -0
  177. package/nitrogen/generated/shared/c++/HybridNativeConsoleSinkSpec.cpp +22 -0
  178. package/nitrogen/generated/shared/c++/HybridNativeConsoleSinkSpec.hpp +64 -0
  179. package/nitrogen/generated/shared/c++/RejectReason.hpp +84 -0
  180. package/nitrogen/generated/shared/c++/RotationConfig.hpp +103 -0
  181. package/nitrogen/generated/shared/c++/SinkStatus.hpp +95 -0
  182. package/package.json +151 -0
  183. package/src/Logger.ts +417 -0
  184. package/src/ScopedLogger.ts +94 -0
  185. package/src/config.ts +25 -0
  186. package/src/destinations/Batcher.ts +805 -0
  187. package/src/destinations/ConsoleDestination.ts +60 -0
  188. package/src/destinations/FileDestination.ts +473 -0
  189. package/src/destinations/NativeConsoleDestination.ts +220 -0
  190. package/src/destinations/types.ts +19 -0
  191. package/src/formatters/DefaultFormatter.ts +150 -0
  192. package/src/formatters/JsonLinesFormatter.ts +195 -0
  193. package/src/formatters/timestamp.ts +12 -0
  194. package/src/formatters/types.ts +29 -0
  195. package/src/index.tsx +106 -0
  196. package/src/integrations/appState.ts +111 -0
  197. package/src/integrations/errorHandler.ts +223 -0
  198. package/src/integrations/sanitizeError.ts +287 -0
  199. package/src/levels.ts +35 -0
  200. package/src/metadata.ts +52 -0
  201. package/src/privacy.ts +389 -0
  202. package/src/specs/FileSink.nitro.ts +144 -0
  203. package/src/specs/NativeConsoleSink.nitro.ts +21 -0
  204. package/src/types.ts +45 -0
  205. 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
+ }