react-native-nitro-logger 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/NitroLogger.podspec +29 -0
- package/README.md +214 -0
- package/android/CMakeLists.txt +24 -0
- package/android/build.gradle +124 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/cpp-adapter.cpp +11 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/AndroidPlatformIo.kt +100 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/HybridFileSink.kt +292 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/HybridNativeConsoleSink.kt +23 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/LogFileWriter.kt +1328 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/LogRotationPolicy.kt +186 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/LogSecureFile.kt +80 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/LogWriterRegistry.kt +420 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/NativeConsoleWriter.kt +237 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/NitroLoggerPackage.kt +22 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/PlatformIo.kt +165 -0
- package/docs/PARITY.md +330 -0
- package/docs/PRIVACY.md +341 -0
- package/eslint-plugin/README.md +238 -0
- package/eslint-plugin/index.js +62 -0
- package/eslint-plugin/rules/literal-subsystem.js +102 -0
- package/eslint-plugin/rules/no-computed-metadata-key.js +174 -0
- package/eslint-plugin/rules/no-derived-correlation.js +171 -0
- package/eslint-plugin/rules/no-dynamic-message.js +60 -0
- package/eslint-plugin/shared.js +1948 -0
- package/ios/Gzip.swift +203 -0
- package/ios/HybridFileSink.swift +291 -0
- package/ios/HybridNativeConsoleSink.swift +20 -0
- package/ios/LogFileWriter.swift +1280 -0
- package/ios/LogSecureFile.swift +296 -0
- package/ios/LogWriterRegistry.swift +444 -0
- package/ios/NativeConsoleWriter.swift +180 -0
- package/lib/module/Logger.js +381 -0
- package/lib/module/Logger.js.map +1 -0
- package/lib/module/ScopedLogger.js +70 -0
- package/lib/module/ScopedLogger.js.map +1 -0
- package/lib/module/config.js +23 -0
- package/lib/module/config.js.map +1 -0
- package/lib/module/destinations/Batcher.js +647 -0
- package/lib/module/destinations/Batcher.js.map +1 -0
- package/lib/module/destinations/ConsoleDestination.js +51 -0
- package/lib/module/destinations/ConsoleDestination.js.map +1 -0
- package/lib/module/destinations/FileDestination.js +360 -0
- package/lib/module/destinations/FileDestination.js.map +1 -0
- package/lib/module/destinations/NativeConsoleDestination.js +170 -0
- package/lib/module/destinations/NativeConsoleDestination.js.map +1 -0
- package/lib/module/destinations/types.js +4 -0
- package/lib/module/destinations/types.js.map +1 -0
- package/lib/module/formatters/DefaultFormatter.js +136 -0
- package/lib/module/formatters/DefaultFormatter.js.map +1 -0
- package/lib/module/formatters/JsonLinesFormatter.js +162 -0
- package/lib/module/formatters/JsonLinesFormatter.js.map +1 -0
- package/lib/module/formatters/timestamp.js +15 -0
- package/lib/module/formatters/timestamp.js.map +1 -0
- package/lib/module/formatters/types.js +4 -0
- package/lib/module/formatters/types.js.map +1 -0
- package/lib/module/index.js +31 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/integrations/appState.js +81 -0
- package/lib/module/integrations/appState.js.map +1 -0
- package/lib/module/integrations/errorHandler.js +166 -0
- package/lib/module/integrations/errorHandler.js.map +1 -0
- package/lib/module/integrations/sanitizeError.js +238 -0
- package/lib/module/integrations/sanitizeError.js.map +1 -0
- package/lib/module/levels.js +35 -0
- package/lib/module/levels.js.map +1 -0
- package/lib/module/metadata.js +52 -0
- package/lib/module/metadata.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/privacy.js +349 -0
- package/lib/module/privacy.js.map +1 -0
- package/lib/module/specs/FileSink.nitro.js +4 -0
- package/lib/module/specs/FileSink.nitro.js.map +1 -0
- package/lib/module/specs/NativeConsoleSink.nitro.js +4 -0
- package/lib/module/specs/NativeConsoleSink.nitro.js.map +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/utf8.js +42 -0
- package/lib/module/utf8.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/Logger.d.ts +122 -0
- package/lib/typescript/src/Logger.d.ts.map +1 -0
- package/lib/typescript/src/ScopedLogger.d.ts +36 -0
- package/lib/typescript/src/ScopedLogger.d.ts.map +1 -0
- package/lib/typescript/src/config.d.ts +12 -0
- package/lib/typescript/src/config.d.ts.map +1 -0
- package/lib/typescript/src/destinations/Batcher.d.ts +244 -0
- package/lib/typescript/src/destinations/Batcher.d.ts.map +1 -0
- package/lib/typescript/src/destinations/ConsoleDestination.d.ts +27 -0
- package/lib/typescript/src/destinations/ConsoleDestination.d.ts.map +1 -0
- package/lib/typescript/src/destinations/FileDestination.d.ts +200 -0
- package/lib/typescript/src/destinations/FileDestination.d.ts.map +1 -0
- package/lib/typescript/src/destinations/NativeConsoleDestination.d.ts +86 -0
- package/lib/typescript/src/destinations/NativeConsoleDestination.d.ts.map +1 -0
- package/lib/typescript/src/destinations/types.d.ts +19 -0
- package/lib/typescript/src/destinations/types.d.ts.map +1 -0
- package/lib/typescript/src/formatters/DefaultFormatter.d.ts +22 -0
- package/lib/typescript/src/formatters/DefaultFormatter.d.ts.map +1 -0
- package/lib/typescript/src/formatters/JsonLinesFormatter.d.ts +73 -0
- package/lib/typescript/src/formatters/JsonLinesFormatter.d.ts.map +1 -0
- package/lib/typescript/src/formatters/timestamp.d.ts +6 -0
- package/lib/typescript/src/formatters/timestamp.d.ts.map +1 -0
- package/lib/typescript/src/formatters/types.d.ts +27 -0
- package/lib/typescript/src/formatters/types.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +34 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/integrations/appState.d.ts +33 -0
- package/lib/typescript/src/integrations/appState.d.ts.map +1 -0
- package/lib/typescript/src/integrations/errorHandler.d.ts +55 -0
- package/lib/typescript/src/integrations/errorHandler.d.ts.map +1 -0
- package/lib/typescript/src/integrations/sanitizeError.d.ts +89 -0
- package/lib/typescript/src/integrations/sanitizeError.d.ts.map +1 -0
- package/lib/typescript/src/levels.d.ts +9 -0
- package/lib/typescript/src/levels.d.ts.map +1 -0
- package/lib/typescript/src/metadata.d.ts +23 -0
- package/lib/typescript/src/metadata.d.ts.map +1 -0
- package/lib/typescript/src/privacy.d.ts +103 -0
- package/lib/typescript/src/privacy.d.ts.map +1 -0
- package/lib/typescript/src/specs/FileSink.nitro.d.ts +131 -0
- package/lib/typescript/src/specs/FileSink.nitro.d.ts.map +1 -0
- package/lib/typescript/src/specs/NativeConsoleSink.nitro.d.ts +21 -0
- package/lib/typescript/src/specs/NativeConsoleSink.nitro.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +37 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/utf8.d.ts +15 -0
- package/lib/typescript/src/utf8.d.ts.map +1 -0
- package/nitro.json +33 -0
- package/nitrogen/generated/android/c++/JAppendResult.hpp +79 -0
- package/nitrogen/generated/android/c++/JClearOutcome.hpp +88 -0
- package/nitrogen/generated/android/c++/JFlushOutcome.hpp +81 -0
- package/nitrogen/generated/android/c++/JHybridFileSinkSpec.cpp +120 -0
- package/nitrogen/generated/android/c++/JHybridFileSinkSpec.hpp +69 -0
- package/nitrogen/generated/android/c++/JHybridNativeConsoleSinkSpec.cpp +71 -0
- package/nitrogen/generated/android/c++/JHybridNativeConsoleSinkSpec.hpp +64 -0
- package/nitrogen/generated/android/c++/JRejectReason.hpp +64 -0
- package/nitrogen/generated/android/c++/JRotationConfig.hpp +77 -0
- package/nitrogen/generated/android/c++/JSinkStatus.hpp +69 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/AppendResult.kt +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/ClearOutcome.kt +66 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/FlushOutcome.kt +81 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/HybridFileSinkSpec.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/HybridNativeConsoleSinkSpec.kt +58 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/RejectReason.kt +25 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/RotationConfig.kt +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/SinkStatus.kt +66 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/nitrologgerOnLoad.kt +35 -0
- package/nitrogen/generated/android/nitrologger+autolinking.cmake +83 -0
- package/nitrogen/generated/android/nitrologger+autolinking.gradle +27 -0
- package/nitrogen/generated/android/nitrologgerOnLoad.cpp +70 -0
- package/nitrogen/generated/android/nitrologgerOnLoad.hpp +34 -0
- package/nitrogen/generated/ios/NitroLogger+autolinking.rb +62 -0
- package/nitrogen/generated/ios/NitroLogger-Swift-Cxx-Bridge.cpp +50 -0
- package/nitrogen/generated/ios/NitroLogger-Swift-Cxx-Bridge.hpp +216 -0
- package/nitrogen/generated/ios/NitroLogger-Swift-Cxx-Umbrella.hpp +69 -0
- package/nitrogen/generated/ios/NitroLoggerAutolinking.mm +41 -0
- package/nitrogen/generated/ios/NitroLoggerAutolinking.swift +38 -0
- package/nitrogen/generated/ios/c++/HybridFileSinkSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridFileSinkSpecSwift.hpp +150 -0
- package/nitrogen/generated/ios/c++/HybridNativeConsoleSinkSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridNativeConsoleSinkSpecSwift.hpp +87 -0
- package/nitrogen/generated/ios/swift/AppendResult.swift +60 -0
- package/nitrogen/generated/ios/swift/ClearOutcome.swift +50 -0
- package/nitrogen/generated/ios/swift/FlushOutcome.swift +59 -0
- package/nitrogen/generated/ios/swift/HybridFileSinkSpec.swift +61 -0
- package/nitrogen/generated/ios/swift/HybridFileSinkSpec_cxx.swift +227 -0
- package/nitrogen/generated/ios/swift/HybridNativeConsoleSinkSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridNativeConsoleSinkSpec_cxx.swift +148 -0
- package/nitrogen/generated/ios/swift/RejectReason.swift +48 -0
- package/nitrogen/generated/ios/swift/RotationConfig.swift +93 -0
- package/nitrogen/generated/ios/swift/SinkStatus.swift +44 -0
- package/nitrogen/generated/shared/c++/AppendResult.hpp +105 -0
- package/nitrogen/generated/shared/c++/ClearOutcome.hpp +96 -0
- package/nitrogen/generated/shared/c++/FlushOutcome.hpp +107 -0
- package/nitrogen/generated/shared/c++/HybridFileSinkSpec.cpp +28 -0
- package/nitrogen/generated/shared/c++/HybridFileSinkSpec.hpp +84 -0
- package/nitrogen/generated/shared/c++/HybridNativeConsoleSinkSpec.cpp +22 -0
- package/nitrogen/generated/shared/c++/HybridNativeConsoleSinkSpec.hpp +64 -0
- package/nitrogen/generated/shared/c++/RejectReason.hpp +84 -0
- package/nitrogen/generated/shared/c++/RotationConfig.hpp +103 -0
- package/nitrogen/generated/shared/c++/SinkStatus.hpp +95 -0
- package/package.json +151 -0
- package/src/Logger.ts +417 -0
- package/src/ScopedLogger.ts +94 -0
- package/src/config.ts +25 -0
- package/src/destinations/Batcher.ts +805 -0
- package/src/destinations/ConsoleDestination.ts +60 -0
- package/src/destinations/FileDestination.ts +473 -0
- package/src/destinations/NativeConsoleDestination.ts +220 -0
- package/src/destinations/types.ts +19 -0
- package/src/formatters/DefaultFormatter.ts +150 -0
- package/src/formatters/JsonLinesFormatter.ts +195 -0
- package/src/formatters/timestamp.ts +12 -0
- package/src/formatters/types.ts +29 -0
- package/src/index.tsx +106 -0
- package/src/integrations/appState.ts +111 -0
- package/src/integrations/errorHandler.ts +223 -0
- package/src/integrations/sanitizeError.ts +287 -0
- package/src/levels.ts +35 -0
- package/src/metadata.ts +52 -0
- package/src/privacy.ts +389 -0
- package/src/specs/FileSink.nitro.ts +144 -0
- package/src/specs/NativeConsoleSink.nitro.ts +21 -0
- package/src/types.ts +45 -0
- package/src/utf8.ts +39 -0
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.
|
package/docs/PRIVACY.md
ADDED
|
@@ -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.
|