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,60 @@
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
+ * Renders to the JS console (Metro in dev). warning → console.warn and
9
+ * error/todo → console.error deliberately: LogBox surfacing errors in dev is
10
+ * a feature, not noise.
11
+ *
12
+ * `outputSink` mirrors SwiftLogger's test hook: when set, lines are also
13
+ * handed to the sink, and `isEnabled` stays true even with printing off.
14
+ */
15
+ export class ConsoleDestination implements LogDestination {
16
+ readonly label = 'console';
17
+ readonly minimumLevel?: LogLevel;
18
+ printEnabled = true;
19
+ outputSink?: (line: string) => void;
20
+
21
+ private readonly formatter: LogFormatter;
22
+
23
+ constructor(options?: { minimumLevel?: LogLevel; formatter?: LogFormatter }) {
24
+ this.minimumLevel = options?.minimumLevel;
25
+ this.formatter = options?.formatter ?? new DefaultFormatter();
26
+ }
27
+
28
+ get isEnabled(): boolean {
29
+ return this.printEnabled || this.outputSink !== undefined;
30
+ }
31
+
32
+ write(entry: LogEntry): void {
33
+ const line = this.formatter.format(entry);
34
+ if (this.outputSink) {
35
+ // Guarded: a faulty observer must not suppress console output or get
36
+ // the whole destination disabled as repeatedly failing.
37
+ try {
38
+ this.outputSink(line);
39
+ } catch {
40
+ // observer-only failure; console printing below still runs
41
+ }
42
+ }
43
+ if (!this.printEnabled) return;
44
+ if (LEVEL_ORDER[entry.level] >= LEVEL_ORDER.error) {
45
+ console.error(line);
46
+ } else if (entry.level === 'warning') {
47
+ console.warn(line);
48
+ } else {
49
+ console.log(line);
50
+ }
51
+ }
52
+
53
+ flush(): void {
54
+ // console writes are synchronous; nothing buffers here
55
+ }
56
+
57
+ dispose(): void {
58
+ this.outputSink = undefined;
59
+ }
60
+ }
@@ -0,0 +1,473 @@
1
+ import type { LogEntry, LogLevel } from '../types';
2
+ import type { LogDestination } from './types';
3
+ import type { LogFormatter } from '../formatters/types';
4
+ import type {
5
+ AppendResult,
6
+ ClearOutcome,
7
+ FlushOutcome,
8
+ RotationConfig,
9
+ SinkStatus,
10
+ } from '../specs/FileSink.nitro';
11
+ import type { BatchFlushOutcome, FenceReason, LossCounts } from './Batcher';
12
+ import { Batcher } from './Batcher';
13
+ import { JsonLinesFormatter } from '../formatters/JsonLinesFormatter';
14
+ import { utf8Length } from '../utf8';
15
+
16
+ /**
17
+ * The part of `FileSink` a `FileDestination` uses.
18
+ *
19
+ * Structural rather than the Nitro interface itself, so the destination can
20
+ * be driven by a scripted double in tests. The real `FileSink` satisfies it.
21
+ */
22
+ export interface FileSinkLike {
23
+ readonly defaultLogDirectory: string;
24
+ open(path: string, rotation?: RotationConfig, lineFramed?: boolean): void;
25
+ appendBatch(batch: string, entryCount: number): AppendResult;
26
+ getStatus(): SinkStatus;
27
+ flush(deadlineMs: number): FlushOutcome;
28
+ close(deadlineMs: number): FlushOutcome;
29
+ getLogFilePaths(): string[];
30
+ /**
31
+ * Delete every artifact and bump the writer generation, fencing every
32
+ * handle on it.
33
+ *
34
+ * On `durable && rebound` the CALLING handle is rebound to the new
35
+ * generation by this call — no second `open` follows, and none would be
36
+ * accepted, since re-opening a live writer is a config conflict. That is why
37
+ * the JS side only lifts its own fence afterwards. A `clearLogs` that bumped
38
+ * the generation without rebinding its caller would leave the destination
39
+ * writing into a stale handle, discarding every record it was handed and
40
+ * disabling itself on the first rejection.
41
+ *
42
+ * The two flags are separate because they are separate facts. `durable`
43
+ * answers the compliance question — the artifacts are gone — and a purge can
44
+ * answer it truthfully and still fail to reopen, on a directory that has
45
+ * become unwritable or a volume that filled. Only the second flag says there
46
+ * is somewhere to write.
47
+ *
48
+ * On anything else the caller stays fenced along with everyone else, so a
49
+ * deletion still in flight can never race a fresh write.
50
+ */
51
+ clearLogs(deadlineMs: number): ClearOutcome;
52
+ }
53
+
54
+ export interface FileDestinationOptions {
55
+ /** Registration key. Default 'file'. */
56
+ readonly label?: string;
57
+ readonly minimumLevel?: LogLevel;
58
+ /** Default `JsonLinesFormatter` — the one with a framing guarantee. */
59
+ readonly formatter?: LogFormatter;
60
+ /** Absolute path. Default `<defaultLogDirectory>/app.log`. */
61
+ readonly path?: string;
62
+ readonly rotation?: RotationConfig;
63
+ /**
64
+ * Largest single rendered record, in UTF-8 bytes. Default 64 KB — roomy for
65
+ * a stack trace, far under the sink's payload cap so a batch always fits.
66
+ */
67
+ readonly maxEntryBytes?: number;
68
+ readonly batchBytes?: number;
69
+ readonly flushIntervalMs?: number;
70
+ readonly maxPendingEntries?: number;
71
+ readonly maxPendingBytes?: number;
72
+ readonly watermarkBytes?: number;
73
+ }
74
+
75
+ export interface PurgeOutcome {
76
+ /** Every artifact is gone. False on any survivor or a blown deadline. */
77
+ readonly durable: boolean;
78
+ /**
79
+ * The destination is writable again, and writing has resumed.
80
+ *
81
+ * False means the deletion may well have succeeded — check `durable` — but
82
+ * the sink did not come back with a usable file, or did not say so. Either
83
+ * way this destination stays disabled until a retry.
84
+ */
85
+ readonly rebound: boolean;
86
+ readonly deletedCount: number;
87
+ readonly failedPaths: readonly string[];
88
+ /** Buffered records thrown away, plus losses that will now go unreported. */
89
+ readonly discardedEntries: number;
90
+ readonly discardedBytes: number;
91
+ }
92
+
93
+ const DEFAULT_LABEL = 'file';
94
+ const DEFAULT_FILENAME = 'app.log';
95
+ const DEFAULT_MAX_ENTRY_BYTES = 64 * 1024;
96
+ const DEFAULT_DEADLINE_MS = 2000;
97
+
98
+ /** Fixed text: a diagnostic that interpolates caller data is a log-injection
99
+ * hole and a privacy hole at once. Only counts travel with it. */
100
+ const LOSS_MESSAGE = 'log entries were dropped';
101
+ const OVERSIZE_MESSAGE = 'a log entry was too large to record';
102
+
103
+ /**
104
+ * Writes formatted records to a native file sink, batched and backpressured.
105
+ *
106
+ * The division of labour is the whole design: this side decides what to write
107
+ * and what to give up on, the sink only appends text and reports counters.
108
+ * Everything interesting — batching, drop policy, loss notices, the purge
109
+ * handshake — is therefore testable without a device.
110
+ *
111
+ * Three behaviours are worth knowing about before using it.
112
+ *
113
+ * **Oversized entries are never sliced.** A formatter that can shed structure
114
+ * is asked to re-render within the limit; one that cannot has its output
115
+ * replaced whole by a fixed notice. Cutting a rendered record to length is
116
+ * how a log file stops being parseable, and an unparseable file is worth less
117
+ * than an honestly incomplete one.
118
+ *
119
+ * **Framing is declared, not assumed.** A formatter guaranteeing one record
120
+ * per line lets the native side trim a torn trailing record after a crash.
121
+ * Without that guarantee the file is left as the crash left it, because a
122
+ * writer cannot tell a torn record from an intentional newline.
123
+ *
124
+ * **A fenced handle stays fenced.** When the sink reports that this handle's
125
+ * generation is stale — someone purged the file underneath it — the buffer is
126
+ * discarded rather than replayed into the new file, and the destination
127
+ * disables itself. Pre-purge records must not reappear after a compliance
128
+ * deletion. The handle that invoked the purge rebinds; any other one does
129
+ * not, and reacquisition is the native registry's job.
130
+ */
131
+ export class FileDestination implements LogDestination {
132
+ readonly label: string;
133
+ readonly minimumLevel: LogLevel | undefined;
134
+
135
+ private readonly sink: FileSinkLike;
136
+ private readonly formatter: LogFormatter;
137
+ private readonly batcher: Batcher;
138
+ private readonly maxEntryBytes: number;
139
+ private readonly path: string;
140
+ /** Held for the M5 registry's reacquisition path, which needs the same
141
+ * config the handle was opened with. */
142
+ private readonly rotation: RotationConfig | undefined;
143
+
144
+ private fenced = false;
145
+ private disposed = false;
146
+
147
+ constructor(sink: FileSinkLike, options: FileDestinationOptions = {}) {
148
+ this.sink = sink;
149
+ this.label = options.label ?? DEFAULT_LABEL;
150
+ this.minimumLevel = options.minimumLevel;
151
+ this.formatter = options.formatter ?? new JsonLinesFormatter();
152
+ this.maxEntryBytes = positive(
153
+ options.maxEntryBytes,
154
+ DEFAULT_MAX_ENTRY_BYTES
155
+ );
156
+ this.path =
157
+ options.path ?? `${sink.defaultLogDirectory}/${DEFAULT_FILENAME}`;
158
+ this.rotation = options.rotation;
159
+
160
+ this.batcher = new Batcher(sink, {
161
+ renderNotice: (lost) => this.renderLossNotice(lost),
162
+ onFenced: (reason) => this.onFenced(reason),
163
+ batchBytes: options.batchBytes,
164
+ flushIntervalMs: options.flushIntervalMs,
165
+ maxPendingEntries: options.maxPendingEntries,
166
+ maxPendingBytes: options.maxPendingBytes,
167
+ watermarkBytes: options.watermarkBytes,
168
+ });
169
+
170
+ // Throws on open failure or on a config conflict with an existing writer,
171
+ // and deliberately so: a file destination that silently writes nowhere is
172
+ // worse than one that refuses to be constructed.
173
+ sink.open(this.path, this.rotation, this.lineFramed);
174
+ }
175
+
176
+ /** True only while this handle may write. */
177
+ get isEnabled(): boolean {
178
+ return !this.fenced && !this.disposed;
179
+ }
180
+
181
+ /** Where records are going. */
182
+ get filePath(): string {
183
+ return this.path;
184
+ }
185
+
186
+ /**
187
+ * Whether the native side may trim a torn trailing record after a crash.
188
+ * Follows the formatter's declaration; nothing infers it.
189
+ */
190
+ get lineFramed(): boolean {
191
+ return this.formatter.framing === 'line';
192
+ }
193
+
194
+ write(entry: LogEntry): void {
195
+ if (!this.isEnabled) return;
196
+ const record = this.renderRecord(entry);
197
+ if (record === undefined) return;
198
+ this.batcher.add(record);
199
+ }
200
+
201
+ /**
202
+ * Drain to disk and fsync, bounded by a wall-clock deadline.
203
+ *
204
+ * Returns the outcome even though `LogDestination` asks only for `void`: a
205
+ * caller flushing before backgrounding or on a crash path needs to know
206
+ * whether it worked, and `durable: false` is the answer that matters.
207
+ */
208
+ flush(deadlineMs: number = DEFAULT_DEADLINE_MS): BatchFlushOutcome {
209
+ return this.batcher.flush(deadlineMs);
210
+ }
211
+
212
+ /** Current file and archives, newest first; empty if the sink refuses. */
213
+ getLogFilePaths(): string[] {
214
+ try {
215
+ return this.sink.getLogFilePaths();
216
+ } catch {
217
+ return [];
218
+ }
219
+ }
220
+
221
+ /** Losses with no notice in the file yet. */
222
+ unreportedLoss(): LossCounts {
223
+ return this.batcher.unreported();
224
+ }
225
+
226
+ /**
227
+ * What has stopped working underneath, as a payload-free bitmask —
228
+ * rotation, gzip, prune, sidecar, protection.
229
+ *
230
+ * The mask exists because the alternative is a message, and a message built
231
+ * from an `errno` string or a path is exactly the kind of thing that carries
232
+ * a username into a log file. Bits carry no payload, so an app can surface
233
+ * "log rotation is failing" to support without surfacing anything else.
234
+ *
235
+ * Non-zero does not mean records are being lost — a failed compression or a
236
+ * failed prune costs disk, not data. Check {@link unreportedLoss} for that.
237
+ */
238
+ degradation(): number {
239
+ return this.batcher.degradation();
240
+ }
241
+
242
+ /**
243
+ * Delete every log artifact, then rebind.
244
+ *
245
+ * The ordering is the point. This handle is fenced first, so nothing can be
246
+ * written into the window where deletion is in flight; the JS buffer is
247
+ * discarded rather than flushed, because flushing it would write pre-purge
248
+ * records into the file a moment before deleting it — or worse, a moment
249
+ * after. Only a durable deletion lifts the fence. A partial or timed-out
250
+ * one leaves this handle disabled until an explicit retry, so a late
251
+ * deletion can never race a fresh write.
252
+ *
253
+ * Discarded counts come back here rather than going into a loss notice: a
254
+ * "4,182 entries dropped" line at the top of a file that was just cleared
255
+ * for compliance describes the clearing, which the caller already knows
256
+ * about.
257
+ */
258
+ purge(deadlineMs: number = DEFAULT_DEADLINE_MS): PurgeOutcome {
259
+ // A disposed destination has closed its sink, and a closed sink has no
260
+ // handle left to delete through — so this call cannot do the one thing it
261
+ // exists to do. Saying so is the entire point: the alternative is a
262
+ // `durable: true` over files that are still on disk, told to the one caller
263
+ // who asked because they are legally obliged to know. `removeDestination()`
264
+ // then a compliance purge is an ordinary sequence, not a contrived one.
265
+ if (this.disposed) {
266
+ return {
267
+ durable: false,
268
+ rebound: false,
269
+ deletedCount: 0,
270
+ failedPaths: [],
271
+ discardedEntries: 0,
272
+ discardedBytes: 0,
273
+ };
274
+ }
275
+
276
+ this.fenced = true;
277
+ const discarded = this.batcher.discard();
278
+
279
+ let outcome: ClearOutcome;
280
+ try {
281
+ outcome = this.sink.clearLogs(deadlineMs);
282
+ } catch {
283
+ return {
284
+ durable: false,
285
+ // The call threw, so nothing is known about the sink's state and the
286
+ // fence set above stays up. Reporting a rebind here would lift it.
287
+ rebound: false,
288
+ deletedCount: 0,
289
+ failedPaths: [],
290
+ discardedEntries: discarded.entries,
291
+ discardedBytes: discarded.bytes,
292
+ };
293
+ }
294
+
295
+ // Both facts, not just the first. `durable` says the artifacts are gone;
296
+ // `rebound` says the sink has a usable file again. Lifting the fence on
297
+ // `durable` alone resumes writing into a handle the sink never rebound —
298
+ // every record accepted, rejected as `staleGeneration`, and dropped. The
299
+ // deletion is still complete and still reported as such; the destination
300
+ // just stays disabled until an explicit retry gets a live file back.
301
+ //
302
+ // `=== true`, not `!== false`. An absent field is not a promise, and the
303
+ // sink most likely to omit it is one that predates it — including a native
304
+ // build that deletes by closing and never reopens at all. A JS bundle can
305
+ // be updated over the air without the binary underneath it changing, so
306
+ // "old native, new JS" is a real pairing, and reading silence as success
307
+ // there rebuilds exactly the silent post-purge loss this flag exists to
308
+ // stop. Staying fenced until a retry is the failure that announces itself.
309
+ const resumed = outcome.durable && outcome.rebound === true;
310
+ if (resumed) {
311
+ this.fenced = this.disposed;
312
+ this.batcher.rebind();
313
+ }
314
+
315
+ return {
316
+ durable: outcome.durable,
317
+ rebound: resumed,
318
+ deletedCount: count(outcome.deletedCount),
319
+ failedPaths: Array.isArray(outcome.failedPaths)
320
+ ? outcome.failedPaths
321
+ : [],
322
+ discardedEntries: discarded.entries,
323
+ discardedBytes: discarded.bytes,
324
+ };
325
+ }
326
+
327
+ /** Idempotent: flush what is buffered, release the timer, close the sink. */
328
+ dispose(): void {
329
+ if (this.disposed) return;
330
+ try {
331
+ this.batcher.flush(DEFAULT_DEADLINE_MS);
332
+ } catch {
333
+ // a failing drain must not leak the handle below
334
+ }
335
+ this.disposed = true;
336
+ this.batcher.dispose();
337
+ try {
338
+ this.sink.close(DEFAULT_DEADLINE_MS);
339
+ } catch {
340
+ // nothing left to salvage
341
+ }
342
+ }
343
+
344
+ // ── Internals ───────────────────────────────────────────────────────────
345
+
346
+ /**
347
+ * Render one entry, or account for why it could not be rendered.
348
+ *
349
+ * Returns undefined when nothing writable came out; the loss is recorded
350
+ * before returning, so an entry is either in the file or in the counters
351
+ * and never in neither.
352
+ */
353
+ private renderRecord(entry: LogEntry): string | undefined {
354
+ const record = this.formatOrUndefined(entry);
355
+ if (record === undefined) {
356
+ this.batcher.noteLoss(1, 0);
357
+ return undefined;
358
+ }
359
+
360
+ // The ORIGINAL size, held onto for the rest of this method. What gets
361
+ // discarded is the entry the caller wrote, so that is the number the
362
+ // counters and the notice have to carry — not the size of whatever
363
+ // undersized floor the formatter came back with instead.
364
+ const bytes = utf8Length(record);
365
+ if (bytes <= this.maxEntryBytes) return record;
366
+
367
+ // Structural shedding first: a formatter that knows its own shape can
368
+ // drop whole fields and truncate one at code-point boundaries, and what
369
+ // comes back is still valid in that format.
370
+ if (this.formatter.formatWithin) {
371
+ const shorter = this.formatWithinOrUndefined(entry);
372
+ if (shorter !== undefined && utf8Length(shorter) <= this.maxEntryBytes) {
373
+ return shorter;
374
+ }
375
+ // Still over: a record has a floor below which it identifies nothing.
376
+ }
377
+
378
+ // Nothing renderable fits, so the entry is replaced whole by a notice in
379
+ // the same format. Never sliced: a fragment of a record is not a record.
380
+ this.batcher.noteLoss(1, bytes);
381
+ return this.boundedNotice(
382
+ noticeEntry(entry.timestamp, OVERSIZE_MESSAGE, { droppedBytes: bytes })
383
+ );
384
+ }
385
+
386
+ private renderLossNotice(lost: LossCounts): string | undefined {
387
+ return this.boundedNotice(
388
+ noticeEntry(Date.now(), LOSS_MESSAGE, {
389
+ droppedEntries: lost.entries,
390
+ droppedBytes: lost.bytes,
391
+ })
392
+ );
393
+ }
394
+
395
+ /**
396
+ * Render one of the pipeline's own notices under the same per-entry limit
397
+ * as everything else.
398
+ *
399
+ * The limit exists so a batch always fits the sink. A notice exempt from it
400
+ * could be refused forever by a sink that will never take it, and the loss
401
+ * it describes would sit undelivered behind it — the pipeline's own
402
+ * diagnostics jamming the pipeline. Returning undefined when nothing fits
403
+ * leaves the loss counted and owed, which is the honest outcome.
404
+ */
405
+ private boundedNotice(entry: LogEntry): string | undefined {
406
+ const text = this.formatOrUndefined(entry);
407
+ if (text === undefined) return undefined;
408
+ if (utf8Length(text) <= this.maxEntryBytes) return text;
409
+ if (this.formatter.formatWithin) {
410
+ const shorter = this.formatWithinOrUndefined(entry);
411
+ if (shorter !== undefined && utf8Length(shorter) <= this.maxEntryBytes) {
412
+ return shorter;
413
+ }
414
+ }
415
+ return undefined;
416
+ }
417
+
418
+ private formatOrUndefined(entry: LogEntry): string | undefined {
419
+ try {
420
+ const text = this.formatter.format(entry);
421
+ return typeof text === 'string' ? text : undefined;
422
+ } catch {
423
+ return undefined;
424
+ }
425
+ }
426
+
427
+ private formatWithinOrUndefined(entry: LogEntry): string | undefined {
428
+ try {
429
+ const text = this.formatter.formatWithin!(entry, this.maxEntryBytes);
430
+ return typeof text === 'string' ? text : undefined;
431
+ } catch {
432
+ return undefined;
433
+ }
434
+ }
435
+
436
+ private onFenced(_reason: FenceReason): void {
437
+ this.fenced = true;
438
+ }
439
+ }
440
+
441
+ /**
442
+ * A synthetic entry describing the pipeline's own state.
443
+ *
444
+ * Built directly rather than routed through the logger: it carries only
445
+ * counts, so there is nothing for redaction to protect, and going through the
446
+ * logger would put it behind the very destination that is trying to report a
447
+ * problem.
448
+ */
449
+ function noticeEntry(
450
+ timestamp: number,
451
+ message: string,
452
+ metadata: Record<string, number>
453
+ ): LogEntry {
454
+ return Object.freeze({
455
+ timestamp,
456
+ level: 'warning' as const,
457
+ message,
458
+ metadata: Object.freeze({ ...metadata }),
459
+ subsystem: 'logger',
460
+ });
461
+ }
462
+
463
+ function positive(value: number | undefined, fallback: number): number {
464
+ return typeof value === 'number' && Number.isFinite(value) && value > 0
465
+ ? Math.floor(value)
466
+ : fallback;
467
+ }
468
+
469
+ function count(value: number): number {
470
+ return typeof value === 'number' && Number.isFinite(value) && value > 0
471
+ ? Math.floor(value)
472
+ : 0;
473
+ }