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,805 @@
1
+ import type {
2
+ AppendResult,
3
+ FlushOutcome,
4
+ RejectReason,
5
+ SinkStatus,
6
+ } from '../specs/FileSink.nitro';
7
+ import { utf8Length } from '../utf8';
8
+
9
+ /**
10
+ * The part of a `FileSink` the batcher drives. Narrow on purpose: it is what
11
+ * lets the whole backpressure and loss protocol be tested against a scripted
12
+ * double, with no native runtime and no filesystem.
13
+ */
14
+ export interface BatchTarget {
15
+ appendBatch(batch: string, entryCount: number): AppendResult;
16
+ getStatus(): SinkStatus;
17
+ flush(deadlineMs: number): FlushOutcome;
18
+ }
19
+
20
+ /** Entries that never reached the file, and the bytes they would have been. */
21
+ export interface LossCounts {
22
+ readonly entries: number;
23
+ readonly bytes: number;
24
+ }
25
+
26
+ /** Why the sink cut this handle off. Both are terminal for the batcher. */
27
+ export type FenceReason = 'staleGeneration' | 'closed';
28
+
29
+ export interface BatcherOptions {
30
+ /**
31
+ * Render the consolidated loss notice in the destination's format. The
32
+ * notice is a record in the log file, so only the destination knows how to
33
+ * spell it.
34
+ *
35
+ * Returning `undefined` (or throwing) leaves the loss unacknowledged rather
36
+ * than silently forgotten — it will be offered again on the next batch.
37
+ */
38
+ readonly renderNotice: (lost: LossCounts) => string | undefined;
39
+ /**
40
+ * Called once when the sink fences this handle. The batcher has already
41
+ * discarded its buffer and baselined its counters by then; the owner
42
+ * decides between rebinding and self-disabling.
43
+ */
44
+ readonly onFenced?: (reason: FenceReason) => void;
45
+ /** Bytes of pending content that trigger a batch. Default 4096. */
46
+ readonly batchBytes?: number;
47
+ /** Idle flush interval, also the backpressure poll interval. Default 100. */
48
+ readonly flushIntervalMs?: number;
49
+ /** Pending-buffer caps; whichever binds first. Default 1000 / 512 KB. */
50
+ readonly maxPendingEntries?: number;
51
+ readonly maxPendingBytes?: number;
52
+ /** Sink queue depth at which pushing pauses. Default 256 KB. */
53
+ readonly watermarkBytes?: number;
54
+ /** Largest single batch. Default 256 KB. */
55
+ readonly maxBatchBytes?: number;
56
+ }
57
+
58
+ /**
59
+ * What a bounded drain achieved.
60
+ *
61
+ * Deliberately not the sink's `FlushOutcome`: the sink reports monotonic
62
+ * per-handle totals, whereas `unreportedEntries` / `unreportedBytes` are the
63
+ * losses that still have no notice in the file. That is the number a caller
64
+ * can act on, and conflating the two is how "we already logged that" turns
65
+ * into a loss no one ever hears about.
66
+ */
67
+ export interface BatchFlushOutcome {
68
+ /** Every accepted byte reached storage, nothing is buffered, no loss is owed. */
69
+ readonly durable: boolean;
70
+ readonly timedOut: boolean;
71
+ /** Bytes not yet durable: still in the JS buffer plus still in the sink. */
72
+ readonly pendingBytes: number;
73
+ readonly unreportedEntries: number;
74
+ readonly unreportedBytes: number;
75
+ /** Sink degradation bitmask, passed through. */
76
+ readonly degraded: number;
77
+ }
78
+
79
+ /** Monotonic totals, JS-side and sink-side, at one instant. */
80
+ interface Cursor {
81
+ sinkEntries: number;
82
+ sinkBytes: number;
83
+ localEntries: number;
84
+ localBytes: number;
85
+ }
86
+
87
+ interface Pending {
88
+ readonly text: string;
89
+ /** UTF-8 bytes including the record's terminating newline. */
90
+ readonly bytes: number;
91
+ }
92
+
93
+ type PushResult = 'sent' | 'full' | 'failed' | 'fenced' | 'empty';
94
+
95
+ /** How far a bounded drain has got. See {@link advanced}. */
96
+ interface Position {
97
+ readonly entries: number;
98
+ readonly bytes: number;
99
+ readonly owedEntries: number;
100
+ readonly owedBytes: number;
101
+ }
102
+
103
+ const DEFAULTS = {
104
+ batchBytes: 4096,
105
+ flushIntervalMs: 100,
106
+ maxPendingEntries: 1000,
107
+ maxPendingBytes: 512 * 1024,
108
+ watermarkBytes: 256 * 1024,
109
+ maxBatchBytes: 256 * 1024,
110
+ };
111
+
112
+ /** Upper bound on any deadline handed to the sink, in milliseconds. */
113
+ const MAX_DEADLINE_MS = 30_000;
114
+
115
+ /** Consecutive no-progress rounds before a bounded drain gives up. */
116
+ const MAX_STALLS = 8;
117
+
118
+ /** Pushes per timer tick, so catching up cannot monopolise the JS thread. */
119
+ const PUSHES_PER_TICK = 4;
120
+
121
+ /**
122
+ * Buffers formatted records and feeds them to a sink under backpressure.
123
+ *
124
+ * Three things happen here that do not happen anywhere else.
125
+ *
126
+ * **Backpressure.** The sink's queue is bounded, so once it passes the
127
+ * watermark the batcher stops pushing and polls `getStatus()` instead — which
128
+ * is why that call is specified never to wait behind writer I/O. Records
129
+ * accumulate in the JS buffer meanwhile, itself bounded by an entry count and
130
+ * a byte count.
131
+ *
132
+ * **Drop-newest.** When the JS buffer is full the arriving record is dropped,
133
+ * not the oldest one held. Under a sustained overload the two policies lose
134
+ * the same volume; they differ in what survives, and the older records are
135
+ * the ones with the context explaining how the overload started.
136
+ *
137
+ * **Loss is owed until it is written.** Every drop — here or in the sink —
138
+ * accrues to a counter that is only cleared once a notice describing it has
139
+ * been accepted, and is re-armed if the flush that should have made that
140
+ * notice durable says otherwise. A loss can therefore be reported twice; it
141
+ * cannot be reported zero times. Silence has to mean nothing was lost, or the
142
+ * counters are decoration.
143
+ */
144
+ export class Batcher {
145
+ private readonly target: BatchTarget;
146
+ private readonly renderNotice: (lost: LossCounts) => string | undefined;
147
+ private readonly onFenced: ((reason: FenceReason) => void) | undefined;
148
+ private readonly batchBytes: number;
149
+ private readonly flushIntervalMs: number;
150
+ private readonly maxPendingEntries: number;
151
+ private readonly maxPendingBytes: number;
152
+ private readonly watermarkBytes: number;
153
+ private readonly maxBatchBytes: number;
154
+
155
+ private pending: Pending[] = [];
156
+ private pendingBytes = 0;
157
+ private paused = false;
158
+ private fenced = false;
159
+ private disposed = false;
160
+ /**
161
+ * A notice could not be delivered on its own — unrenderable, or refused by
162
+ * the sink — and nothing about ticking again would change that.
163
+ *
164
+ * The debt stays owed and still shows in `unreported()`; what stops is the
165
+ * retrying on a timer, which would go on for the life of the process
166
+ * achieving nothing. The next record to arrive reopens it — that retry
167
+ * rides a batch that carries the record, so it cannot spin — as does an
168
+ * explicit `flush()`.
169
+ */
170
+ private noticeBlocked = false;
171
+ private timer: ReturnType<typeof setTimeout> | undefined;
172
+
173
+ /** Highest sink totals seen, and every byte this side has dropped. */
174
+ private observed: Cursor = zeroCursor();
175
+ /** Covered by a notice the sink has accepted. */
176
+ private acked: Cursor = zeroCursor();
177
+ /** Covered by a notice a durable flush has confirmed. */
178
+ private confirmed: Cursor = zeroCursor();
179
+
180
+ private degraded = 0;
181
+
182
+ constructor(target: BatchTarget, options: BatcherOptions) {
183
+ this.target = target;
184
+ this.renderNotice = options.renderNotice;
185
+ this.onFenced = options.onFenced;
186
+ this.batchBytes = positive(options.batchBytes, DEFAULTS.batchBytes);
187
+ this.flushIntervalMs = positive(
188
+ options.flushIntervalMs,
189
+ DEFAULTS.flushIntervalMs
190
+ );
191
+ this.maxPendingEntries = positive(
192
+ options.maxPendingEntries,
193
+ DEFAULTS.maxPendingEntries
194
+ );
195
+ this.maxPendingBytes = positive(
196
+ options.maxPendingBytes,
197
+ DEFAULTS.maxPendingBytes
198
+ );
199
+ this.watermarkBytes = positive(
200
+ options.watermarkBytes,
201
+ DEFAULTS.watermarkBytes
202
+ );
203
+ this.maxBatchBytes = positive(
204
+ options.maxBatchBytes,
205
+ DEFAULTS.maxBatchBytes
206
+ );
207
+ }
208
+
209
+ // ── Producing ───────────────────────────────────────────────────────────
210
+
211
+ /**
212
+ * Buffer one formatted record. Never performs I/O beyond handing a full
213
+ * batch to the sink, and never throws: a logging call must not fail the
214
+ * code that made it.
215
+ */
216
+ add(record: string): void {
217
+ if (this.disposed || this.fenced) {
218
+ this.dropped(record);
219
+ return;
220
+ }
221
+ const bytes = utf8Length(record) + 1; // the record's own newline
222
+ if (
223
+ this.pending.length >= this.maxPendingEntries ||
224
+ this.pendingBytes + bytes > this.maxPendingBytes
225
+ ) {
226
+ this.dropped(record, bytes);
227
+ this.ensureTimer();
228
+ return;
229
+ }
230
+
231
+ this.pending.push({ text: record, bytes });
232
+ this.pendingBytes += bytes;
233
+ // A record to ride with is a fresh chance for a notice the sink refused
234
+ // earlier, and the reason it refused is usually temporary — a queue that
235
+ // was full a moment ago. Retrying on this event cannot spin the way a
236
+ // timer can: the batch carries a record, so it makes progress either way,
237
+ // and if the notice fails again the dead ends below block it again.
238
+ // Waiting instead for the next explicit flush means a process that dies
239
+ // first reports the loss zero times.
240
+ this.noticeBlocked = false;
241
+
242
+ if (!this.paused && this.pendingBytes >= this.batchBytes) {
243
+ this.pushOnce();
244
+ }
245
+ this.ensureTimer();
246
+ }
247
+
248
+ /**
249
+ * Record a loss that happened before the batcher saw it — a record that
250
+ * could not be rendered, or one too large to be rendered honestly. It is
251
+ * owed a notice on exactly the same terms as anything dropped here.
252
+ */
253
+ noteLoss(entries: number, bytes: number): void {
254
+ this.observed.localEntries += toCount(entries);
255
+ this.observed.localBytes += toCount(bytes);
256
+ this.ensureTimer();
257
+ }
258
+
259
+ /** Losses with no notice in the file yet. */
260
+ unreported(): LossCounts {
261
+ return this.delta();
262
+ }
263
+
264
+ /** Bytes buffered on this side, not yet handed to the sink. */
265
+ bufferedBytes(): number {
266
+ return this.pendingBytes;
267
+ }
268
+
269
+ /**
270
+ * Payload-free bitmask of what has stopped working underneath — rotation,
271
+ * gzip, prune, sidecar, protection.
272
+ *
273
+ * This mirrors the sink's own mask, which accumulates bits and drops them
274
+ * only when a durable purge baselines everything. That is deliberate: these
275
+ * are things an app wants to know happened *at all*, and a mask that cleared
276
+ * itself the moment the next batch succeeded would report nothing the one
277
+ * time it mattered.
278
+ */
279
+ degradation(): number {
280
+ return this.degraded;
281
+ }
282
+
283
+ isFenced(): boolean {
284
+ return this.fenced;
285
+ }
286
+
287
+ // ── Draining ────────────────────────────────────────────────────────────
288
+
289
+ /**
290
+ * Drain to the sink and fsync, bounded by a wall-clock deadline.
291
+ *
292
+ * Backpressure is overridden for the duration: the whole point of a flush
293
+ * is to push through a full queue rather than wait for it to clear, so a
294
+ * rejected batch is followed by a bounded sink flush and retried.
295
+ *
296
+ * The postcondition is honest rather than absolute. Under a disk that stays
297
+ * full, this returns `durable: false` with the losses preserved — the
298
+ * alternative, insisting everything is either durable or noticed, is a
299
+ * promise no implementation can keep.
300
+ */
301
+ flush(deadlineMs: number): BatchFlushOutcome {
302
+ this.cancelTimer();
303
+ if (this.disposed || this.fenced) return this.outcome(false, false, 0);
304
+
305
+ const deadlineAt = Date.now() + clampDeadline(deadlineMs);
306
+ this.paused = false;
307
+ // An explicit flush is the retry: a notice the sink refused on its own
308
+ // gets one more chance here before being set aside again.
309
+ this.noticeBlocked = false;
310
+
311
+ let timedOut = false;
312
+ let stalls = 0;
313
+ let attempts = 0;
314
+ const maxAttempts = this.maxPendingEntries + MAX_STALLS * 2 + 16;
315
+
316
+ while (this.pending.length > 0 || this.owesNotice()) {
317
+ if (attempts >= maxAttempts || Date.now() >= deadlineAt) {
318
+ timedOut = true;
319
+ break;
320
+ }
321
+ attempts += 1;
322
+
323
+ const before = this.position();
324
+ const result = this.pushOnce();
325
+ if (result === 'empty' || result === 'fenced') break;
326
+
327
+ if (result === 'full') {
328
+ // Ask the sink to write what it has, then retry.
329
+ const remaining = deadlineAt - Date.now();
330
+ if (remaining <= 0) {
331
+ timedOut = true;
332
+ break;
333
+ }
334
+ const mid = this.targetFlush(remaining);
335
+ if (mid.timedOut) timedOut = true;
336
+ }
337
+
338
+ // Progress is MEASURED, never inferred from the result: a notice-only
339
+ // batch that fails removes no records and settles no debt, and a sink
340
+ // that finds a fresh loss on every append can hold the debt constant
341
+ // indefinitely — both look like progress to a check that only asks
342
+ // whether the push said 'failed'.
343
+ if (advanced(before, this.position())) {
344
+ stalls = 0;
345
+ } else {
346
+ stalls += 1;
347
+ if (stalls >= MAX_STALLS) break;
348
+ }
349
+ }
350
+
351
+ const final = this.targetFlush(Math.max(0, deadlineAt - Date.now()));
352
+ if (final.timedOut) timedOut = true;
353
+
354
+ const settled =
355
+ final.durable && this.pending.length === 0 && !this.owesNotice();
356
+ if (settled) {
357
+ // Every notice accepted so far is now on disk.
358
+ this.confirmed = { ...this.acked };
359
+ } else {
360
+ // An accepted notice that no flush has confirmed is not a notice yet.
361
+ // Re-arming risks writing it twice, which beats losing it.
362
+ this.acked = { ...this.confirmed };
363
+ }
364
+
365
+ // The final sync is itself a place losses are discovered — a batch the
366
+ // writer could not land shows up in the counters it returns. Re-arm, or a
367
+ // loss learned about here waits for the next log call to be reported, and
368
+ // an app that has stopped logging never makes one.
369
+ this.ensureTimer();
370
+
371
+ return this.outcome(settled, timedOut, final.pendingBytes);
372
+ }
373
+
374
+ /**
375
+ * Release timers and let the buffer go. Idempotent.
376
+ *
377
+ * Deliberately does not flush: `LogDestination` calls `flush()` before
378
+ * `dispose()`, and a dispose that quietly blocked on a stalled disk would
379
+ * turn tearing down a destination into a hang.
380
+ */
381
+ dispose(): void {
382
+ this.cancelTimer();
383
+ this.disposed = true;
384
+ this.pending = [];
385
+ this.pendingBytes = 0;
386
+ }
387
+
388
+ /**
389
+ * Forget everything buffered and start the loss counters over, returning
390
+ * what was discarded.
391
+ *
392
+ * This is the purge path. The counts go back to the caller instead of into
393
+ * a notice, because a "4,182 entries dropped" line written into a file that
394
+ * was just cleared for compliance reasons describes the clearing, and the
395
+ * caller already knows it cleared the file.
396
+ */
397
+ discard(): LossCounts {
398
+ const discardedEntries = this.pending.length;
399
+ const discardedBytes = this.pendingBytes;
400
+ this.pending = [];
401
+ this.pendingBytes = 0;
402
+ this.syncCounters();
403
+ const owed = this.delta();
404
+ this.baseline();
405
+ this.paused = false;
406
+ return {
407
+ entries: discardedEntries + owed.entries,
408
+ bytes: discardedBytes + owed.bytes,
409
+ };
410
+ }
411
+
412
+ /**
413
+ * Resume against a fresh generation after the owner has rebound.
414
+ *
415
+ * The sink cursors are re-read from scratch rather than carried over,
416
+ * because the old totals are not a baseline for the new handle's. Whether
417
+ * the sink resets its per-handle counters on rebind or keeps them running
418
+ * is not something this side can know — and getting it wrong is silent in
419
+ * the worse direction: keeping a stale maximum would swallow every new loss
420
+ * smaller than the pre-purge total, reporting it zero times.
421
+ *
422
+ * Local counters are deliberately left alone. Records this side dropped
423
+ * after the fence went up are real drops of real entries, and they belong
424
+ * to the new file as much as to the old one.
425
+ */
426
+ rebind(): void {
427
+ this.fenced = false;
428
+ this.paused = false;
429
+ this.noticeBlocked = false;
430
+ this.observed.sinkEntries = 0;
431
+ this.observed.sinkBytes = 0;
432
+ this.syncCounters();
433
+ this.acked.sinkEntries = this.observed.sinkEntries;
434
+ this.acked.sinkBytes = this.observed.sinkBytes;
435
+ this.confirmed.sinkEntries = this.observed.sinkEntries;
436
+ this.confirmed.sinkBytes = this.observed.sinkBytes;
437
+ }
438
+
439
+ // ── Internals ───────────────────────────────────────────────────────────
440
+
441
+ /**
442
+ * Hand at most one batch to the sink.
443
+ *
444
+ * The loss snapshot is taken BEFORE the call and acknowledged to that
445
+ * snapshot rather than to the totals the call returns. A drop that happens
446
+ * during this very append is not described by the notice riding in it, and
447
+ * acknowledging the post-call totals would bury it.
448
+ */
449
+ private pushOnce(): PushResult {
450
+ const lines: string[] = [];
451
+ let entryCount = 0;
452
+ let entryBytes = 0;
453
+ for (const item of this.pending) {
454
+ if (entryCount > 0 && entryBytes + item.bytes > this.maxBatchBytes) break;
455
+ lines.push(item.text);
456
+ entryBytes += item.bytes;
457
+ entryCount += 1;
458
+ }
459
+
460
+ const snapshot: Cursor = { ...this.observed };
461
+ const owed = this.delta();
462
+ let carriesNotice = false;
463
+ if (!this.noticeBlocked && (owed.entries > 0 || owed.bytes > 0)) {
464
+ const notice = this.noticeText(owed);
465
+ if (notice !== undefined) {
466
+ lines.push(notice);
467
+ carriesNotice = true;
468
+ } else if (entryCount === 0) {
469
+ // A notice that will not render, and nothing else to send. Trying
470
+ // again in a hundred milliseconds will produce the same nothing, so
471
+ // the debt is set aside rather than retried on a timer for the life
472
+ // of the process. It is still owed, and an explicit flush reopens it.
473
+ this.noticeBlocked = true;
474
+ }
475
+ }
476
+ if (lines.length === 0) return 'empty';
477
+
478
+ // Whether this batch is carrying the notice and nothing else, which is
479
+ // what makes a rejection unrecoverable by retrying: there is no record to
480
+ // give up to make it smaller, and no smaller notice to send.
481
+ const noticeOnly = entryCount === 0;
482
+
483
+ // The notice goes after the records it does not describe, so a reader
484
+ // meets the surviving entries first and the gap where the others were.
485
+ const batch = `${lines.join('\n')}\n`;
486
+
487
+ let result: AppendResult;
488
+ try {
489
+ result = this.target.appendBatch(batch, lines.length);
490
+ } catch {
491
+ // A throwing sink is a failing sink. The batch is gone either way.
492
+ this.loseHead(entryCount, entryBytes);
493
+ if (noticeOnly) this.noticeBlocked = true;
494
+ return 'failed';
495
+ }
496
+
497
+ this.observeCounts(result.lostEntries, result.lostBytes, result.degraded);
498
+
499
+ if (result.accepted) {
500
+ this.loseHead(entryCount, 0, false);
501
+ if (carriesNotice) this.acked = snapshot;
502
+ if (toCount(result.queuedBytes) >= this.watermarkBytes)
503
+ this.paused = true;
504
+ return 'sent';
505
+ }
506
+
507
+ const reason: RejectReason = result.rejectReason ?? 'failed';
508
+ if (reason === 'staleGeneration' || reason === 'closed') {
509
+ this.fence(reason);
510
+ return 'fenced';
511
+ }
512
+ if (reason === 'full') {
513
+ // An empty sink that still refuses means the batch is larger than the
514
+ // sink can ever hold, not that it is busy. Waiting for room that will
515
+ // never come wedges every later record behind this one.
516
+ if (toCount(result.queuedBytes) === 0) {
517
+ if (carriesNotice) {
518
+ // The notice may be the whole reason this did not fit, so it goes
519
+ // first — before any record is given up for it. Setting it aside
520
+ // beats pausing on a sink that has already given its final answer:
521
+ // the debt is kept and offered again on the next explicit flush,
522
+ // and meanwhile records are not held behind a line about old ones.
523
+ this.noticeBlocked = true;
524
+ return 'failed';
525
+ }
526
+ const head = this.pending[0];
527
+ if (head !== undefined) {
528
+ // Records alone, and still too big: give up the oldest and try a
529
+ // smaller batch.
530
+ this.loseHead(1, head.bytes);
531
+ }
532
+ return 'failed';
533
+ }
534
+ this.paused = true;
535
+ return 'full';
536
+ }
537
+ this.loseHead(entryCount, entryBytes);
538
+ // A notice-only batch the sink rejected outright leaves the state exactly
539
+ // as it was: nothing removed, nothing settled. Retrying it on the timer
540
+ // is a loop with no exit.
541
+ if (noticeOnly) this.noticeBlocked = true;
542
+ return 'failed';
543
+ }
544
+
545
+ /**
546
+ * Remove the batch's records from the head of the buffer, optionally
547
+ * counting them as lost.
548
+ */
549
+ private loseHead(count: number, bytes: number, lost = true): void {
550
+ if (count === 0) return;
551
+ const removed = this.pending.splice(0, count);
552
+ for (const item of removed) this.pendingBytes -= item.bytes;
553
+ if (this.pendingBytes < 0) this.pendingBytes = 0;
554
+ if (lost) {
555
+ this.observed.localEntries += count;
556
+ this.observed.localBytes += bytes;
557
+ }
558
+ }
559
+
560
+ private dropped(record: string, bytes?: number): void {
561
+ this.observed.localEntries += 1;
562
+ this.observed.localBytes += bytes ?? utf8Length(record) + 1;
563
+ }
564
+
565
+ private fence(reason: FenceReason): void {
566
+ // Pre-purge records must never be replayed into a post-purge file, and a
567
+ // notice about them is a statement about data that was deliberately
568
+ // deleted. Both go.
569
+ this.pending = [];
570
+ this.pendingBytes = 0;
571
+ this.syncCounters();
572
+ this.baseline();
573
+ this.fenced = true;
574
+ this.paused = false;
575
+ this.cancelTimer();
576
+ try {
577
+ this.onFenced?.(reason);
578
+ } catch {
579
+ // the owner's problem, not the pipeline's
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Bring the sink's counters up to date without writing anything.
585
+ *
586
+ * Cursors normally advance off append results, which means a loss the sink
587
+ * has recorded but not yet had occasion to report is invisible here. Before
588
+ * drawing a line under the past — a purge, a fence — that window has to be
589
+ * closed, or a loss from the old file crosses over and gets described as if
590
+ * it had happened to the new one.
591
+ */
592
+ private syncCounters(): void {
593
+ try {
594
+ const status = this.target.getStatus();
595
+ this.observeCounts(status.lostEntries, status.lostBytes, status.degraded);
596
+ } catch {
597
+ // An unreadable probe just means the line is drawn where we stand.
598
+ }
599
+ }
600
+
601
+ /** Declare every loss seen so far as settled, reporting none of it. */
602
+ private baseline(): void {
603
+ this.acked = { ...this.observed };
604
+ this.confirmed = { ...this.observed };
605
+ }
606
+
607
+ private noticeText(lost: LossCounts): string | undefined {
608
+ let text: string | undefined;
609
+ try {
610
+ text = this.renderNotice(lost);
611
+ } catch {
612
+ return undefined;
613
+ }
614
+ return typeof text === 'string' && text.length > 0 ? text : undefined;
615
+ }
616
+
617
+ private delta(): LossCounts {
618
+ return {
619
+ entries: Math.max(
620
+ 0,
621
+ this.observed.sinkEntries -
622
+ this.acked.sinkEntries +
623
+ (this.observed.localEntries - this.acked.localEntries)
624
+ ),
625
+ bytes: Math.max(
626
+ 0,
627
+ this.observed.sinkBytes -
628
+ this.acked.sinkBytes +
629
+ (this.observed.localBytes - this.acked.localBytes)
630
+ ),
631
+ };
632
+ }
633
+
634
+ private owesNotice(): boolean {
635
+ const owed = this.delta();
636
+ return owed.entries > 0 || owed.bytes > 0;
637
+ }
638
+
639
+ /** Where the drain has got to: records still buffered, loss still owed. */
640
+ private position(): Position {
641
+ const owed = this.delta();
642
+ return {
643
+ entries: this.pending.length,
644
+ bytes: this.pendingBytes,
645
+ owedEntries: owed.entries,
646
+ owedBytes: owed.bytes,
647
+ };
648
+ }
649
+
650
+ /**
651
+ * Fold sink counters in. They are monotonic per handle, so the maximum
652
+ * seen is the truth; a sink that reported a smaller number than last time
653
+ * would otherwise drive the delta negative and cancel a real loss.
654
+ */
655
+ private observeCounts(
656
+ lostEntries: number,
657
+ lostBytes: number,
658
+ degraded: number
659
+ ): void {
660
+ this.observed.sinkEntries = Math.max(
661
+ this.observed.sinkEntries,
662
+ toCount(lostEntries)
663
+ );
664
+ this.observed.sinkBytes = Math.max(
665
+ this.observed.sinkBytes,
666
+ toCount(lostBytes)
667
+ );
668
+ this.degraded = toCount(degraded);
669
+ }
670
+
671
+ private targetFlush(deadlineMs: number): FlushOutcome {
672
+ let outcome: FlushOutcome;
673
+ try {
674
+ outcome = this.target.flush(clampDeadline(deadlineMs));
675
+ } catch {
676
+ return {
677
+ durable: false,
678
+ timedOut: true,
679
+ pendingBytes: 0,
680
+ queuedBytes: 0,
681
+ lostBytes: this.observed.sinkBytes,
682
+ lostEntries: this.observed.sinkEntries,
683
+ degraded: this.degraded,
684
+ };
685
+ }
686
+ this.observeCounts(
687
+ outcome.lostEntries,
688
+ outcome.lostBytes,
689
+ outcome.degraded
690
+ );
691
+ return outcome;
692
+ }
693
+
694
+ private outcome(
695
+ durable: boolean,
696
+ timedOut: boolean,
697
+ sinkPending: number
698
+ ): BatchFlushOutcome {
699
+ const owed = this.delta();
700
+ return {
701
+ durable,
702
+ timedOut,
703
+ // Both sides of the handoff: what never left the JS buffer, plus what
704
+ // the sink took but has not made durable.
705
+ pendingBytes: this.pendingBytes + toCount(sinkPending),
706
+ unreportedEntries: owed.entries,
707
+ unreportedBytes: owed.bytes,
708
+ degraded: this.degraded,
709
+ };
710
+ }
711
+
712
+ // ── Timer ───────────────────────────────────────────────────────────────
713
+
714
+ private ensureTimer(): void {
715
+ if (this.timer !== undefined || this.disposed || this.fenced) return;
716
+ // A blocked notice is not a reason to keep ticking: the sink has already
717
+ // refused it, and the next explicit flush is what retries.
718
+ const owed = this.owesNotice() && !this.noticeBlocked;
719
+ if (this.pending.length === 0 && !this.paused && !owed) return;
720
+ this.timer = setTimeout(() => {
721
+ this.timer = undefined;
722
+ this.onTick();
723
+ }, this.flushIntervalMs);
724
+ }
725
+
726
+ private cancelTimer(): void {
727
+ if (this.timer !== undefined) {
728
+ clearTimeout(this.timer);
729
+ this.timer = undefined;
730
+ }
731
+ }
732
+
733
+ private onTick(): void {
734
+ if (this.disposed || this.fenced) return;
735
+
736
+ if (this.paused) {
737
+ // The non-enqueuing probe: this is the call that is specified to be
738
+ // answerable while the writer thread is stalled on disk, and the whole
739
+ // pause/resume cycle rests on that.
740
+ try {
741
+ const status = this.target.getStatus();
742
+ this.observeCounts(
743
+ status.lostEntries,
744
+ status.lostBytes,
745
+ status.degraded
746
+ );
747
+ if (toCount(status.queuedBytes) < this.watermarkBytes) {
748
+ this.paused = false;
749
+ }
750
+ } catch {
751
+ // stay paused; try again next tick
752
+ }
753
+ }
754
+
755
+ if (!this.paused) {
756
+ for (let i = 0; i < PUSHES_PER_TICK; i += 1) {
757
+ const result = this.pushOnce();
758
+ if (result !== 'sent') break;
759
+ if (this.paused) break;
760
+ }
761
+ }
762
+
763
+ this.ensureTimer();
764
+ }
765
+ }
766
+
767
+ function zeroCursor(): Cursor {
768
+ return { sinkEntries: 0, sinkBytes: 0, localEntries: 0, localBytes: 0 };
769
+ }
770
+
771
+ /**
772
+ * Did the drain get anywhere between these two positions?
773
+ *
774
+ * The two halves are compared separately rather than summed, because a batch
775
+ * the sink refused moves records out of the buffer and into the debt they now
776
+ * owe a notice — a conserved total, and real progress. Summing them says the
777
+ * drain is stuck at exactly the moment it is working through a failing sink,
778
+ * and abandons the records queued behind the ones it just gave up on.
779
+ */
780
+ function advanced(before: Position, after: Position): boolean {
781
+ if (after.entries < before.entries || after.bytes < before.bytes) return true;
782
+ return (
783
+ after.owedEntries < before.owedEntries || after.owedBytes < before.owedBytes
784
+ );
785
+ }
786
+
787
+ /** A count from across the bridge; anything not a positive number is zero. */
788
+ function toCount(value: number): number {
789
+ return typeof value === 'number' && Number.isFinite(value) && value > 0
790
+ ? Math.floor(value)
791
+ : 0;
792
+ }
793
+
794
+ function positive(value: number | undefined, fallback: number): number {
795
+ return typeof value === 'number' && Number.isFinite(value) && value > 0
796
+ ? Math.floor(value)
797
+ : fallback;
798
+ }
799
+
800
+ function clampDeadline(value: number): number {
801
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
802
+ return 0;
803
+ }
804
+ return Math.min(Math.floor(value), MAX_DEADLINE_MS);
805
+ }