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
package/docs/PARITY.md ADDED
@@ -0,0 +1,330 @@
1
+ # Parity with SwiftLogger
2
+
3
+ This package is a port of [SwiftLogger](https://github.com/amirshayegh/logger).
4
+ Where the two can produce the same bytes, they do — `JsonLinesFormatter` is
5
+ asserted byte-identical to `JSONLogFormatter` over a generated corpus in
6
+ `__tests__/jsonLinesFormatter.test.ts`. Where they cannot, this file says why.
7
+
8
+ Nothing here is aspirational. Every "identical" row is covered by a golden;
9
+ every difference is either enforced by a test or unreachable through the
10
+ public API, and the table says which.
11
+
12
+ ## JSON record
13
+
14
+ Field order is fixed on both sides so output diffs cleanly.
15
+
16
+ | Field | SwiftLogger | This package | Status |
17
+ | ------------- | ------------------------------------ | -------------------------------- | ------ |
18
+ | `timestamp` | ISO 8601 UTC ms, or epoch seconds | same | identical |
19
+ | `level` | `VERBOSE`…`TODO` | same | identical |
20
+ | `message` | RFC 8259 escaped string | same | identical |
21
+ | `correlation` | omitted when absent | same | identical |
22
+ | `subsystem` | omitted when absent | same | identical |
23
+ | `file` | emitted when non-empty | **never emitted** | differs |
24
+ | `function` | emitted when non-empty | **never emitted** | differs |
25
+ | `line` | **always emitted** | **never emitted** | differs |
26
+ | `metadata` | sorted keys, omitted when empty | same | identical |
27
+ | `truncated` | — | added by `formatWithin` | extension |
28
+
29
+ ### Why no `file`, `function`, `line`
30
+
31
+ Swift captures these for free with `#fileID`, `#function`, and `#line`. JavaScript
32
+ has no equivalent, and the alternative — parsing a Hermes stack trace on every
33
+ log call — is both expensive and unreliable across Hermes versions and release
34
+ builds. Subsystems are the filtering mechanism instead.
35
+
36
+ This is the only structural difference in the record, and the parity test
37
+ encodes it exactly: it strips `,"line":N` from each golden and requires
38
+ byte equality on everything that remains. `file` and `function` are absent
39
+ from the goldens because the corpus leaves them empty, which is what a port
40
+ with no call-site capture would produce anyway.
41
+
42
+ ### Escaping
43
+
44
+ Identical, and not by coincidence: `JSON.stringify` on a string produces the
45
+ same bytes as SwiftLogger's hand-rolled escaper for every input Swift can
46
+ represent — the two mandatory escapes, the `\b \f \n \r \t` shorthands,
47
+ lowercase `\u00XX` for the rest of C0, and everything else literal including
48
+ non-ASCII. Goldens cover each class.
49
+
50
+ One case exists only on this side. A JavaScript string can hold an unpaired
51
+ surrogate; a Swift `String` cannot. Written raw it would make the log file
52
+ invalid UTF-8, so it is escaped as `\udXXX`, which is also what `JSON.stringify`
53
+ does. No golden covers it because Swift cannot produce one.
54
+
55
+ ### Metadata values
56
+
57
+ | Value | SwiftLogger | This package | Status |
58
+ | ------------ | ------------------------------ | ------------------- | ------ |
59
+ | string | quoted | quoted | identical |
60
+ | bool | `true` / `false` | same | identical |
61
+ | integer | `LogValue.int` → `42` | `42` | identical |
62
+ | fractional | `LogValue.double` → `1.5` | `1.5` | identical |
63
+ | whole double | `LogValue.double(3)` → `3.0` | `3` | differs |
64
+ | non-finite | quoted `"nan"` / `"inf"` | quoted `"NaN"` / `"Infinity"` | differs, unreachable |
65
+
66
+ The last two rows are both consequences of JavaScript having one numeric type
67
+ where Swift has two. `LogValue.double(3.0)` renders `3.0`; a JavaScript `3` is
68
+ indistinguishable from `3.0` and renders `3`. The values are numerically
69
+ identical and both are valid JSON — a consumer parsing the field gets the same
70
+ number either way.
71
+
72
+ Non-finite values differ only in spelling, and redaction rejects them before a
73
+ formatter ever sees one (`__tests__/redaction.test.ts`). The branch exists so a
74
+ hand-built entry produces a parseable record rather than a bare `NaN` token.
75
+
76
+ ### Timestamps
77
+
78
+ `iso8601` is identical, including the pre-1970 case where flooring rather than
79
+ truncating toward zero decides the date — `-1500 ms` renders
80
+ `1969-12-31T23:59:58.500Z` on both sides, and there is a golden for it.
81
+
82
+ `epochSeconds` differs textually for whole seconds: Swift's `Double` rendering
83
+ gives `1769516130.0` where JavaScript gives `1769516130`. Numerically equal,
84
+ and the JSON type is a number on both sides. `iso8601` is the default and the
85
+ style the goldens are written against.
86
+
87
+ Out-of-range instants have no Swift analogue to match — Swift falls back to a
88
+ `DateFormatter`, and this package clamps to the epoch rather than letting
89
+ `toISOString` throw inside a log call that was already reporting a problem.
90
+
91
+ ## Console record
92
+
93
+ `DefaultFormatter` follows SwiftLogger's console layout minus the same
94
+ call-site column:
95
+
96
+ ```
97
+ LEVEL | HH:mm:ss.SSS | [correlation] [subsystem] message {key=value}
98
+ ```
99
+
100
+ Level tags are the byte-identical five-character forms (`TRACE`, `DEBUG`,
101
+ ` INFO`, ` WARN`, `ERROR`, ` TODO`), and metadata renders as `key=value` sorted
102
+ by key. The timestamp is local time on both sides.
103
+
104
+ ### Control characters in structured fields
105
+
106
+ | Field | SwiftLogger | This package |
107
+ | ------------------------ | ----------- | -------------------- |
108
+ | `correlation` | escaped | escaped |
109
+ | `subsystem` | escaped | escaped |
110
+ | metadata keys and values | escaped | escaped |
111
+ | `message` | **verbatim**| **line-break safe** |
112
+
113
+ Both sides render controls in the structured fields as `\n`, `\r`, `\t`, or
114
+ `\u{HH}` with uppercase hex. SwiftLogger covers C0 and DEL; this package also
115
+ covers **C1 (U+0080–U+009F)**, which is easy to overlook and worth the
116
+ divergence: U+009B is a single-character CSI, so a terminal reads what follows
117
+ it as a cursor-movement or erase sequence, and U+0085 is NEL, a line break to
118
+ any Unicode-aware reader.
119
+
120
+ This layout puts one entry per line, so a newline inside any field lets
121
+ whoever supplied it forge whole entries — a correlation ID, a subsystem, a
122
+ metadata key or value, or a message can each arrive from a request header, a
123
+ username, a URL, or an error. `"a\nERROR | 00:00:00.000 | "` would otherwise
124
+ write a fake line a reader cannot distinguish from a real one.
125
+
126
+ **The message is where the two diverge, deliberately.** SwiftLogger leaves it
127
+ verbatim so the crash handler can log multi-line stack traces. This package
128
+ wanted the same thing without the forgery, so it keeps the lines and indents
129
+ every one after the first under the message column:
130
+
131
+ ```
132
+ ERROR | 12:15:30.842 | Unhandled TypeError
133
+ | | at foo (bundle.js:1:2)
134
+ ```
135
+
136
+ A real record always carries a known five-character level tag and a timestamp
137
+ of digits, so a blank in either column cannot be read as a header. Five
138
+ sequences count as line breaks — `\n`, `\r\n`, a bare `\r`, U+0085, and
139
+ U+2028/U+2029 — and every other control character, C0 through C1, is escaped
140
+ as in the structured fields.
141
+
142
+ ### The JSON record does not do this
143
+
144
+ `JsonLinesFormatter` escapes exactly what RFC 8259 requires, byte-identically
145
+ to SwiftLogger: C0 escaped, everything else literal, C1 included. That is not
146
+ an oversight.
147
+
148
+ Its `framing: 'line'` contract is defined over **`\n` bytes**, because that is
149
+ what the native crash-tail trimmer scans for. A raw U+0085 or U+009B inside a
150
+ JSON string encodes to `C2 85` / `C2 9B` — no `0x0A` byte anywhere — so record
151
+ boundaries survive, and the record is still valid JSON that any parser reads
152
+ back exactly as written. Escaping them would buy nothing and break byte parity
153
+ with the goldens.
154
+
155
+ The distinction is that a console line is read by a terminal, and a JSON line
156
+ is read by a parser.
157
+
158
+ This package escaped none of these fields until the parity matrix was written.
159
+ The structured-field gap was found by comparing against the Swift
160
+ implementation; the message gap was found in review of that fix, on the
161
+ grounds that matching the reference is not the same as being safe. Both are
162
+ the argument for keeping this document accurate.
163
+
164
+ ## Beyond the formatter
165
+
166
+ | Area | SwiftLogger | This package |
167
+ | ---- | ----------- | ------------ |
168
+ | Privacy | `os_log`-style interpolation privacy | `pub()` / `priv()` markers plus `privacyDefault`, enforced at runtime and by an ESLint plugin |
169
+ | Metadata keys | unconstrained | validated, optionally catalog-restricted |
170
+ | Call-site capture | `#fileID` / `#function` / `#line` | none, by design |
171
+ | Per-file level overrides | supported | dropped for v1 |
172
+ | `highlight()` | supported | dropped for v1 |
173
+
174
+ The privacy layer is the substantive divergence. Swift's model leans on
175
+ `os_log` privacy interpolation, which has no equivalent in a JavaScript logger
176
+ that also writes files; this package resolves visibility itself, before an
177
+ entry is constructed. `eslint-plugin/README.md` covers what that does and does
178
+ not protect.
179
+
180
+ ---
181
+
182
+ # Parity between the two native writers
183
+
184
+ The Android writer is a port of the iOS one. Structure, naming scheme, clamping
185
+ rules, the two-lock split, generation fencing and the durable/rebound split are
186
+ identical by intent — a rotation or a purge that behaves differently on the two
187
+ platforms is a bug report nobody can reproduce.
188
+
189
+ The rows below are the places where they genuinely differ, and why. Everything
190
+ not listed here is the same on both platforms and covered by suites that assert
191
+ the same invariants: 122 XCTest cases and 102 JUnit cases, neither of which needs
192
+ a simulator or an emulator.
193
+
194
+ | Concern | iOS | Android | Consequence |
195
+ | ------- | --- | ------- | ----------- |
196
+ | Open-and-check | `O_RDWR\|O_APPEND\|O_CREAT\|O_NOFOLLOW\|O_NONBLOCK` in one syscall, then `fstat` | check-then-open: `lstat` for a symlink, then `FileOutputStream` | a window on Android, over app-private storage no other app can write to |
197
+ | Crash-tail trim | through the same descriptor it will append with | a separate `RandomAccessFile` opened before the append stream exists | the trim and the writes provably concern one inode on iOS; on Android that rests on there being no other descriptor yet |
198
+ | File age across restarts | `creationDate` from the filesystem | `<base>.meta` sidecar, authoritative once written | see below |
199
+ | At-rest protection | `NSFileProtectionCompleteUntilFirstUserAuthentication` and a backup-exclusion flag, per artifact | `noBackupFilesDir` plus owner-only modes | **not equivalent** — see below |
200
+ | Link count / directory sync | `fstat` and `fsync` directly | behind `PlatformIo`, so the writer imports nothing from `android.*` | the Android writer is JVM-testable; `PlatformIo.Jvm` reports "cannot say" for link count, so that path is driven by a fake |
201
+ | Deadlines | `DispatchTime` | injected monotonic clock (`System.nanoTime`) | same guarantee, reached differently |
202
+ | Console chunk size | 900 bytes per `os_log` entry | 3800 bytes per logcat entry | the platform limits genuinely differ; the behaviour around them — `(i/n)` markers, 8-chunk ceiling, a truncation notice that fits inside its own entry — is identical |
203
+ | Console split boundary | grapheme clusters (`Character`) | code points | iOS also keeps combining sequences whole; Android only guarantees surrogate pairs are not cut. Both prevent replacement characters, which is the corruption that matters — see below |
204
+
205
+ ## Why the console split boundary differs
206
+
207
+ Both platforms drop the tail of an over-long console line in silence — no
208
+ ellipsis, no diagnostic — so both writers split it themselves and mark the
209
+ pieces. What they will not do is split *inside a character*, because that
210
+ produces replacement characters in the console and suggests corruption that is
211
+ not there.
212
+
213
+ iOS splits on `Character`, which is an extended grapheme cluster, so a flag
214
+ emoji and a combining sequence both stay whole. Android splits on code points,
215
+ which keeps surrogate pairs — the emoji — together but can separate a combining
216
+ mark from the letter it modifies.
217
+
218
+ The gap is deliberate. Matching iOS exactly means `BreakIterator` and therefore
219
+ ICU, whose version varies by device, in the one code path that runs on every
220
+ console line of every app. The failure it would prevent is a combining mark
221
+ rendering on its own at a chunk boundary: visually odd, still legible, and
222
+ still valid UTF-8. The failure both platforms *do* prevent is a half-encoded
223
+ character, which is neither.
224
+
225
+ ## At-rest protection is not equivalent
226
+
227
+ The table says "not equivalent" rather than "different mechanism, same result",
228
+ because that would be a claim neither platform supports.
229
+
230
+ iOS applies a data-protection class, so between boot and the first unlock the
231
+ file's contents are cryptographically unavailable even to code that can reach
232
+ the path. Android's log directory is app-private and excluded from Auto Backup,
233
+ and every artifact is chmod'd to its owner, but the writer requests no
234
+ per-file protection class — there is no Android equivalent to ask for. Whatever
235
+ protection the artifacts have at rest is whatever platform storage encryption
236
+ provides for that device and that user state.
237
+
238
+ So the difference is narrow and specific: **before first unlock**, iOS gives the
239
+ log file a guarantee the Android one does not have. After first unlock the two
240
+ are much closer, and neither is a defence against an attacker who already has
241
+ privileged access to the device — that case is out of scope for both, and
242
+ `docs/PRIVACY.md` says so.
243
+
244
+ The platforms also differ in how much they have to *withhold*. On iOS all three
245
+ protections — mode, protection class, backup exclusion — are applied only to a
246
+ log directory the writer itself created; one that was already there is inspected
247
+ and left alone, because each of them is directory-wide, outlives the sink, and
248
+ the log path may well be a directory the host app owns and shares. Android has
249
+ nothing to withhold at that level: `restrictToOwner` sets modes and nothing
250
+ else, and the Auto Backup exclusion comes from living under `noBackupFilesDir`
251
+ rather than from an attribute the writer sets. Android's Kotlin therefore still
252
+ tightens a pre-existing log directory's mode where iOS now reports it instead.
253
+ That is a deliberate difference and the one place the two writers' directory
254
+ handling is not identical: the case iOS is protecting — a directory deliberately
255
+ shared with an app group or an extension — has no Android counterpart. App
256
+ storage there is uid-isolated with no cross-process sharing to strip, and a
257
+ directory on external storage does not honour POSIX modes in the first place.
258
+ Files are treated the same on both, and unlike directories they are treated as
259
+ owned whether or not the writer created them — a log file already sitting at the
260
+ log path is one this writer is about to append to, rotate, and purge, so it is
261
+ its artifact by every meaning that matters, and a `0644` one full of patient
262
+ data is worth tightening. Every writer-managed file gets everything its platform
263
+ offers.
264
+
265
+ Both platforms can also fall short of what they attempt: applying or verifying
266
+ a mode can fail, and the writer records a `protection` degradation and keeps
267
+ logging rather than refusing to log at all. So "owner-only modes on every
268
+ artifact" describes what is applied and reported, not an invariant that always
269
+ holds. Read the `degraded` bitmask if the distinction matters to you.
270
+
271
+ ## Why Android needs a sidecar
272
+
273
+ `BasicFileAttributes.creationTime()` is API 26, and this library supports 24.
274
+ Worse, where it does exist it is not reliably populated: several Android
275
+ filesystems have no birth time and return the mtime instead, which advances on
276
+ every write. A writer that trusted it would see a freshly created file at every
277
+ restart and postpone age-based rotation forever.
278
+
279
+ So `<base>.meta` holds the epoch millis the current file began, and it is
280
+ authoritative once written — the filesystem is consulted only to seed a sidecar
281
+ that does not exist yet, and only when its answer is positive and not in the
282
+ future. The sidecar is part of the artifact naming scheme, so a purge takes it.
283
+ Rotation overwrites it with the fresh timestamp and holds that value in memory
284
+ until the write lands, so a failed write followed by a failed reopen cannot
285
+ send the process back to the archived file's age.
286
+
287
+ ## `java.nio.file` is not available
288
+
289
+ API 26 again, and it is worth stating separately because the failure is not a
290
+ graceful one: an unresolvable class raises `NoClassDefFoundError`, which is an
291
+ `Error` and passes straight through `catch (Exception)`.
292
+
293
+ Every use of `java.nio.file` in the Android sources is confined to
294
+ `PlatformIo.Jvm.Nio`, a private object nothing on Android ever calls. The
295
+ enclosing `PlatformIo.Jvm` *is* reachable from Android — `AndroidPlatformIo`
296
+ falls back to its `restrictToOwner`, which uses only `java.io` — which is
297
+ exactly why `Nio` is held separately: resolving `Jvm` must not drag
298
+ `java.nio.file` into an Android class load.
299
+
300
+ Android answers symlink detection and tri-state existence with `Os.lstat`
301
+ (API 21), and path normalisation is done textually. It does not answer creation
302
+ time at all: `AndroidPlatformIo.creationTimeMillis` returns null unconditionally,
303
+ because the API-26 call is both unavailable below 26 and unreliable above it,
304
+ and the sidecar is the mechanism instead.
305
+
306
+ `platform` is a required parameter on both `LogFileWriter.open` and
307
+ `LogWriterRegistry.acquire` specifically so that falling back to the JVM
308
+ implementation is not expressible.
309
+
310
+ ## Compatibility, per platform
311
+
312
+ These claims are deliberately narrower than "React Native ≥ 0.78 on both
313
+ platforms", because only one of them is verified against the bottom of the
314
+ range.
315
+
316
+ | Platform | Claim | How it is backed |
317
+ | -------- | ----- | ---------------- |
318
+ | iOS | React Native ≥ 0.78 | the `min-rn-ios` CI job packs a tarball into a pristine 0.78 app, builds it Release, launches it on a simulator and reads a run-ID-matched verdict out of the app container |
319
+ | Android | verified at the example's React Native version; **0.78 experimental** | `test-android` runs the writer's JUnit suite and `build-android` builds the example, but no equivalent minimum-version consumer job exists yet |
320
+
321
+ The Android gap is tracked for v1.1. Narrowing the claim was the deliberate
322
+ alternative to asserting something no job checks.
323
+
324
+ `min-rn-ios` is pinned to `macos-15` with Xcode 16.4. React Native 0.78 pins
325
+ fmt 11.0.2 through RCT-Folly and clang from Xcode 26 rejects its
326
+ `format-inl.h`; that is upstream and unrelated to this library, but it is also
327
+ not a combination any consumer is in, since an app on 0.78 builds with the
328
+ toolchain 0.78 shipped against. The cost of keeping the floor at 0.78 is that
329
+ this job is pinned to a runner image GitHub will eventually retire — raising
330
+ the floor is what would let it move.
@@ -0,0 +1,341 @@
1
+ # The privacy contract
2
+
3
+ This library was built for an app that must never write patient data to a log
4
+ file. That requirement shaped every design decision below, and it is worth
5
+ saying plainly what the library does and does not promise.
6
+
7
+ **What it promises.** Values you pass as metadata are redacted or rendered
8
+ according to a rule you choose once, at startup. Redaction happens before an
9
+ entry is constructed, so a redacted value is never present in any object a
10
+ destination can see. The reveal path — the code that renders a private value in
11
+ the clear — is `__DEV__`-gated and provably absent from a release bundle.
12
+
13
+ **What it cannot promise.** Three things, and none of them is a detail:
14
+
15
+ - It cannot tell whether a string is a name. If you interpolate a medical record
16
+ number into a message, this library writes it to disk. The message field is
17
+ public by contract and constrained by lint, not at runtime.
18
+ - That lint is **opt-in**. Installing this package does not enable it.
19
+ - The redaction above is **release-build behaviour**. A debug build renders
20
+ private payloads in the clear, to every destination including the file.
21
+
22
+ Each is expanded below. The short version is that this library makes the safe
23
+ thing the default in the builds that ship, and cannot make it the only thing.
24
+
25
+ ---
26
+
27
+ ## Two profiles
28
+
29
+ The default is the OSS-friendly one. Switching the *metadata* default to
30
+ fail-closed is one method call; the rest of the telehealth profile is not.
31
+ Regulated use also requires enabling the ESLint configuration in your own
32
+ setup and running it in CI, and — where your key vocabulary can carry an
33
+ identifier — registering an approved-key catalog. `privacyDefault('private')`
34
+ does neither of those for you.
35
+
36
+ ```ts
37
+ import { Log } from 'react-native-nitro-logger';
38
+
39
+ // In the app's entry point, before anything logs.
40
+ Log.privacyDefault('private');
41
+ ```
42
+
43
+ **In a release build** (`__DEV__` false):
44
+
45
+ | | `'public'` (default) | `'private'` |
46
+ | --- | --- | --- |
47
+ | A bare metadata value | rendered | redacted to `<private>` |
48
+ | `pub(v)` | rendered | rendered |
49
+ | `priv(v)` | redacted | redacted |
50
+ | Forgetting a wrapper | leaks | hides |
51
+
52
+ **In a debug build the redaction column does not apply.** The reveal branch is
53
+ `__DEV__`-gated, so a development build renders private payloads in the clear —
54
+ to *every* configured destination, not just the console, because visibility is
55
+ resolved before the entry is constructed and the file sink receives whatever
56
+ that produced. A debug build is therefore not suitable for real regulated data;
57
+ see [The compliance boundary](#the-compliance-boundary).
58
+
59
+ `'private'` is fail-closed, which is the entire reason it exists: in the builds
60
+ that ship, the failure mode of a forgotten wrapper is a missing value in a log
61
+ file, not a patient identifier in one.
62
+
63
+ `privacyDefault` is **first-set-wins, then tighten-only**. The first call
64
+ decides; later calls can move `'public'` → `'private'` but never back. A
65
+ library you depend on cannot loosen your setting, and a second call in a
66
+ different module cannot silently undo the one in your entry point.
67
+
68
+ `redactAllMetadata()` is the same idea taken to the end: every value redacts,
69
+ wrapped or not, permanently.
70
+
71
+ ---
72
+
73
+ ## What may be a metadata value
74
+
75
+ Only exact primitives: `string`, finite `number`, `boolean`, and the
76
+ `pub()`/`priv()` markers wrapping one of those.
77
+
78
+ Everything else is dropped without being rendered — objects, arrays, proxies,
79
+ `NaN`, `Infinity`, functions, symbols, `null`, `undefined`. Dropping is
80
+ payload-free: the entry gains a `droppedMetadataCount` integer and nothing
81
+ else. A rejected value never appears in output, not even as a type name, since
82
+ `String(value)` on a hostile object runs `toString` and that is somebody else's
83
+ code deciding what your log file contains.
84
+
85
+ Wrapper payloads are validated **twice** — once when `pub()`/`priv()` is called
86
+ and again when the value is unwrapped at emit. The second check is not
87
+ redundant. `pub(patient as any)` type-checks fine, and only the unwrap sees
88
+ what actually arrived.
89
+
90
+ Markers are recognised by membership in a module-private `WeakMap`, never by a
91
+ property or a `Symbol`. A foreign object shaped like a `PublicValue` is not
92
+ one, and fails validation naturally rather than by a check somebody has to
93
+ remember to write.
94
+
95
+ The payload is held in that `WeakMap` too, so there is no enumerable field to
96
+ find. `JSON.stringify`, spreading, `String()`, and `console.log` inspection all
97
+ yield the placeholder.
98
+
99
+ ### Metadata keys
100
+
101
+ Keys must match `^[A-Za-z0-9._-]{1,64}$` at runtime. That stops a key from
102
+ carrying a payload, but it does not stop `patient123` from being a key — which
103
+ is why the strict profile adds an **approved-key catalog**:
104
+
105
+ ```ts
106
+ Log.metadataKeyCatalog(['requestId', 'statusCode', 'durationMs', 'retryCount']);
107
+ ```
108
+
109
+ With a catalog set, an unrecognised key is dropped and counted, in both direct
110
+ and scoped metadata. Fail-closed, and the only mechanism here that stops a
111
+ literal PHI key.
112
+
113
+ Under `privacyDefault('private')` the catalog is **mandatory, and enforced by
114
+ the runtime rather than by convention**: with no catalog configured, no key is
115
+ approved, so every metadata key drops and is counted. Choosing the strict
116
+ profile and then forgetting the catalog leaves none of the metadata you
117
+ supplied — only the injected `droppedMetadataCount`, which is exactly the
118
+ signal that this is what happened. Loud, and in the safe direction; not a
119
+ silent failure open.
120
+
121
+ The practical consequence is that the strict profile takes two calls, not one:
122
+
123
+ ```ts
124
+ Log.privacyDefault('private');
125
+ Log.metadataKeyCatalog(['requestId', 'statusCode', 'durationMs', 'retryCount']);
126
+ ```
127
+
128
+ This applies to metadata the library itself emits. The crash handler logs under
129
+ `errorName`, `errorMessage`, `errorFrames`, `errorFrameCount`,
130
+ `errorFramesTruncated` and `fatal` — under `'private'`, catalog those six or
131
+ crash reports arrive with their metadata stripped. Their *values* are already
132
+ `pub()`-marked at the call site, because this package generates every one of
133
+ them and none can carry caller data; but a `pub()` value does not exempt a key
134
+ from the catalog, since the catalog exists to police key *names*.
135
+
136
+ `droppedMetadataCount` is the exception and needs no entry. It is added after
137
+ filtering rather than passing through it, and is rejected as an incoming key,
138
+ so the count of what was dropped can never itself be dropped.
139
+
140
+ ---
141
+
142
+ ## Public by contract
143
+
144
+ Four things are **not** redacted at runtime, because redacting them would make
145
+ the logger useless — a log of `<private>` lines is not a log. They are
146
+ constrained at build time instead, by the bundled ESLint plugin.
147
+
148
+ **This enforcement is opt-in and does nothing until you turn it on.** Installing
149
+ the package ships the rules; it does not apply them. Until you extend one of the
150
+ configs below in your own ESLint setup *and* run ESLint in CI, nothing stops a
151
+ template literal in a message position, and the guarantees in this section do
152
+ not hold for your codebase.
153
+
154
+ | Field | Rule | Enforced by |
155
+ | --- | --- | --- |
156
+ | `message` | string literal or approved constant | `no-dynamic-message` |
157
+ | metadata keys | no computed keys, no object spreads | `no-computed-metadata-key` |
158
+ | `correlation` | must come from `Log.newCorrelationId()` | `no-derived-correlation` |
159
+ | `subsystem` | literal constant | `literal-subsystem` |
160
+
161
+ ```js
162
+ // eslint.config.mjs
163
+ import nitroLogger from 'react-native-nitro-logger/eslint-plugin';
164
+
165
+ export default [nitroLogger.configs.strict];
166
+ ```
167
+
168
+ `recommended` enables the first two. `strict` enables all four and is what the
169
+ telehealth profile means.
170
+
171
+ The rules reject variables, concatenation, template interpolation, calls,
172
+ conditionals and dynamic thunk bodies in the message position. There are
173
+ fixture tests for the ways people get around that by accident — aliasing,
174
+ destructuring, wrappers, `call`/`apply` — and the plugin is run over `.js`
175
+ files in CI as well as `.ts`.
176
+
177
+ **Correlation IDs must be random and short-lived.** `Log.newCorrelationId()`
178
+ returns one — an opaque string, not a branded type, because the enforcement is
179
+ in the lint rule rather than in the type system. Do not derive them from a
180
+ record identifier, and do not hash one: a hash of a small identifier space is
181
+ trivially reversible, and it joins across sessions exactly as well as the
182
+ original did.
183
+
184
+ `no-derived-correlation` checks *provenance*, not spelling. A local
185
+ `function newCorrelationId() { return patient.mrn }` produces exactly the leak
186
+ the rule exists to prevent, so the generator has to be traceable to an import
187
+ of this package.
188
+
189
+ ---
190
+
191
+ ## The compliance boundary
192
+
193
+ **A build where reveal is possible is a build for synthetic data only.**
194
+
195
+ The reveal branch is `__DEV__`-gated, and there is no API to turn it on at
196
+ runtime. That absence is asserted rather than assumed: a test sweeps every
197
+ function the privacy module exports, calls each one with a `priv('SECRET')`,
198
+ and fails if any of them hands the payload back. Adding a reveal path to that
199
+ module breaks the test. Three CI jobs cover the rest:
200
+
201
+ - **`build-library`** inventories every `__DEV__` in the built output. It fails
202
+ when a new gated module appears *and* when an allowlisted one disappears —
203
+ the second because a vanished gate would quietly make the next check vacuous.
204
+ - **`privacy-release-bundle`** bundles with `--dev false` and requires both that
205
+ the reveal sentinels are absent and that a witness token from each gated
206
+ module is present. Without the witness, the check would pass just as happily
207
+ if the module were never bundled at all.
208
+ - The TypeScript suite asserts the runtime absence: with `__DEV__` false, a
209
+ private value renders as `<private>`.
210
+
211
+ What this does *not* cover: a debug build renders private payloads in the clear,
212
+ and it does so to **every configured destination**. Redaction is resolved before
213
+ an entry is constructed, so a revealed value reaches the file sink and the
214
+ system console exactly as it reaches `console.log` — and the file sink writes it
215
+ to disk, where it outlives the session that produced it.
216
+
217
+ That is the point of a debug build, and it is why the boundary is stated as a
218
+ rule about data rather than a rule about builds: the enforcement is that reveal
219
+ cannot reach production, not that reveal cannot exist.
220
+
221
+ If you run a debug build against real regulated data, understand what can and
222
+ cannot be undone. `FileDestination.purge()` removes the file sink's artifacts —
223
+ that is the recoverable part. Everything the same entry handed to `os_log` or
224
+ `logcat` is **not** recoverable: those are the operating system's stores, this
225
+ library has no delete API for them, and on iOS the unified log is readable from
226
+ a connected Mac. There is no call in this package that clears them. The only
227
+ reliable control over that copy is not producing it in the first place, which is
228
+ what the boundary above is for.
229
+
230
+ ### Logs on disk
231
+
232
+ The file sink writes to app-private storage — `noBackupFilesDir` on Android,
233
+ `Library/Logs` on iOS. Owner-only modes (0700 directories, 0600 files) are
234
+ applied to every artifact, and on iOS each artifact also gets
235
+ `NSFileProtectionCompleteUntilFirstUserAuthentication` and a backup-exclusion
236
+ flag.
237
+
238
+ **A log directory the writer did not create is inspected, never changed.** The
239
+ log path is yours to choose, and nothing stops it being a directory your app
240
+ already owns and uses. Every protection here is a property of the *directory*
241
+ and outlives the sink: the backup exclusion is persistent and directory-wide,
242
+ the protection class is inherited by every file created there afterwards, and
243
+ even `0700` strips access from a directory that may be deliberately shared with
244
+ an app group or an extension. Point a sink at `<Documents>/app.log` and claiming
245
+ those would silently take your whole document tree out of iCloud backup.
246
+
247
+ So if the directory was already there, the writer leaves it exactly as it found
248
+ it and reports a loose mode as a `protection` degradation for you to act on. If
249
+ the writer created the directory, it is unambiguously the writer's and gets all
250
+ three. *Files* are different and always get all three: a log file at the log
251
+ path is one the writer is about to append to, rotate and purge, so it counts as
252
+ its own even if it was already there — and a `0644` log file left by an earlier
253
+ version is exactly the thing worth tightening. Android has nothing to scope —
254
+ its equivalent is structural (`noBackupFilesDir`), and its `restrictToOwner`
255
+ only sets modes.
256
+
257
+ The practical consequence: **if you supply your own log directory, create it
258
+ with the protections you want.** This applies to the iOS default too —
259
+ `Library/Logs` is a standard system directory and may well already exist, in
260
+ which case the writer inspects it like any other. That is not a gap: what the
261
+ backup exclusion and the protection class are actually protecting is the log
262
+ files, and those are the writer's own artifacts, so they get both regardless of
263
+ who made the directory around them.
264
+
265
+ Those are applied, not guaranteed. When the platform refuses, the writer records
266
+ a `protection` bit in the `degraded` bitmask and keeps logging rather than
267
+ failing shut — losing a mode is worth reporting, not worth dropping the app's
268
+ logs over. Read `degradation()` if you need to know. The two platforms are also
269
+ not equivalent here: iOS's protection class makes the file cryptographically
270
+ inaccessible until first unlock, and Android has no per-file counterpart.
271
+
272
+ **`FileDestination.purge(deadlineMs)` is the compliance primitive.** It is
273
+ synchronous and returns a `PurgeOutcome`. It deletes every artifact the writer
274
+ can produce — active file, age sidecar, archives, and interrupted gzip staging
275
+ files — bumps a generation so nothing in flight can write into the fresh file,
276
+ and `fsync`s the directory so the removals survive a crash.
277
+
278
+ Its reach is the file sink. It does not and cannot clear what other
279
+ destinations did with the same entry; see the paragraph above on `os_log` and
280
+ `logcat`.
281
+
282
+ `durable` means every *pre-purge* artifact is gone and the directory has been
283
+ synced, so the removals hold across a crash. It is not a claim that the log
284
+ directory is left empty: a purge that succeeds and rebinds immediately opens a
285
+ fresh, empty active file, because the alternative is a destination that accepts
286
+ records with nowhere to put them. `durable` describes the data that was there.
287
+
288
+ `durable` is false on any survivor, any deadline overrun, and any path where
289
+ deletion failed *or could not be proven to have succeeded*: a file the platform
290
+ cannot make a statement about is reported as a failure, never as a deletion.
291
+ `rebound` is reported separately, because a complete deletion can still be
292
+ followed by a failed reopen, and the destination stays fenced until it comes
293
+ back. `discardedEntries` and `discardedBytes` say what was thrown away from the
294
+ JS buffer, which is deliberately discarded rather than flushed — flushing it
295
+ would write pre-purge records into the file moments before or after deleting it.
296
+
297
+ `getLogFilePaths()` exists so an app can implement its own consent-gated
298
+ support-log upload. Collecting and transmitting logs is deliberately not in
299
+ this library: that needs a consent flow and an encryption story that belong to
300
+ the application, not to its logger.
301
+
302
+ ---
303
+
304
+ ## Threat model, briefly
305
+
306
+ Assumed hostile: values arriving as metadata, thrown values reaching the
307
+ uncaught-error handler, and configuration numbers arriving from JavaScript.
308
+ Where each is checked differs, and it is worth being exact:
309
+
310
+ | Input | Validated in TypeScript | Validated natively |
311
+ | --- | --- | --- |
312
+ | Metadata values and keys | yes — primitives only, twice for wrapper payloads | **no**: native receives the already-rendered string, never the original value |
313
+ | Thrown values | yes — every read guarded, message and frames sanitised | **no**, same reason |
314
+ | Rotation and retention numbers | yes | yes — re-clamped independently, since `NaN` and `Infinity` cross the bridge as ordinary numbers |
315
+ | Batch payload and entry count | yes | partly — the count must be a finite integer in range, and an empty payload with a nonzero count (or the reverse) is rejected; a nonzero count is **not** re-derived from the payload |
316
+
317
+ The last row is worth being blunt about, and worth naming the layer each check
318
+ sits in. The count crosses the bridge as a `double`, and both platforms refuse
319
+ it unless it converts to an integer exactly: `Int(exactly:)` on iOS,
320
+ `BridgeNumber.exactLong` on Android. Neither rounds — `2.5` is rejected, not
321
+ silently turned into `2` — and neither accepts a NaN or an infinity, both of
322
+ which a bare cast would quietly turn into a plausible number. The writer then
323
+ bounds the range and rejects an empty payload carrying a nonzero count, or the
324
+ reverse.
325
+
326
+ What no layer does is parse the batch to confirm the number. Native trusts a
327
+ plausible count. It drives loss accounting rather than what gets written, so a
328
+ wrong-but-plausible count skews a statistic rather than corrupting the file.
329
+
330
+ So "validated on both sides" is true of the numbers and of the batch framing,
331
+ and not of the content. The content is resolved to a string in JavaScript, and
332
+ the native side's job is to write that string without corrupting it, not to
333
+ second-guess it.
334
+
335
+ Assumed trusted: the application's own string literals, the device's storage,
336
+ and the developer's build configuration. A logger cannot defend against an app
337
+ that decides to log a patient record on purpose, and pretending otherwise would
338
+ be worse than saying so.
339
+
340
+ Out of scope: cross-process access to the log directory (there is no extension
341
+ use case), and an attacker with root on the device.