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/src/Logger.ts ADDED
@@ -0,0 +1,417 @@
1
+ import type { LazyMessage, LogEntry, LogLevel, LogMetadata } from './types';
2
+ import type { LogDestination } from './destinations/types';
3
+ import type { PrivacyDefault, PrivacySettings } from './privacy';
4
+ import { ConsoleDestination } from './destinations/ConsoleDestination';
5
+ import { resolveSubsystemLevel } from './config';
6
+ import { levelAtLeast } from './levels';
7
+ import {
8
+ buildCatalog,
9
+ normalizePrivacyDefault,
10
+ redactMetadata,
11
+ } from './privacy';
12
+ import { ScopedLogger } from './ScopedLogger';
13
+
14
+ export interface LogOptions {
15
+ level?: LogLevel;
16
+ subsystem?: string;
17
+ metadata?: LogMetadata;
18
+ /** Scope defaults, outranked by `metadata` on any key they share. Kept a
19
+ * separate field rather than pre-merged: redaction settles precedence per
20
+ * key and validates the winner before reading it, which a merge would
21
+ * defeat by reading everything first. `ScopedLogger` sets this. */
22
+ scopeMetadata?: LogMetadata;
23
+ correlation?: string;
24
+ }
25
+
26
+ /** A destination is cut off after this many consecutive write failures. */
27
+ const MAX_CONSECUTIVE_FAILURES = 5;
28
+
29
+ /**
30
+ * A registered destination plus the label captured once, at registration.
31
+ * The label is never re-read afterwards: a getter that succeeds during
32
+ * registration and throws later would otherwise break removal, and failure
33
+ * accounting needs a stable key or a misbehaving destination can never be
34
+ * disabled.
35
+ */
36
+ interface Registration {
37
+ readonly destination: LogDestination;
38
+ readonly label: string;
39
+ }
40
+
41
+ /**
42
+ * The logger. Access through the `Log` singleton.
43
+ *
44
+ * Everything runs on the JS thread, so unlike the Swift original there is no
45
+ * lock or config-snapshot machinery — plain fields mutated by the fluent
46
+ * methods are safe by construction.
47
+ *
48
+ * Pipeline per message: resolve effective level (exact subsystem → parent
49
+ * walk → global) → collect destinations passing isEnabled AND minimumLevel →
50
+ * return BEFORE evaluating a lazy message when none remain → evaluate once,
51
+ * guarded → prepare metadata (redaction seam; M2 adds privacy wrappers) →
52
+ * freeze → fan out with per-destination isolation.
53
+ *
54
+ * Destinations are treated as untrusted: every property read and call is
55
+ * guarded so one faulty implementation can neither crash the app nor starve
56
+ * its siblings.
57
+ */
58
+ export class Logger {
59
+ private globalMinimum: LogLevel = 'debug';
60
+ private readonly subsystemLevels = new Map<string, LogLevel>();
61
+ private registrations: Registration[] = defaultRegistrations();
62
+
63
+ /** Consecutive write-failure counts per destination label. */
64
+ private readonly failureCounts = new Map<string, number>();
65
+ private readonly disabledLabels = new Set<string>();
66
+
67
+ // Privacy state. Every transition tightens; nothing here can be loosened
68
+ // at runtime, so a compromised or careless call site cannot turn redaction
69
+ // off in a shipped build.
70
+ private privacyDefaultValue: PrivacyDefault = 'public';
71
+ private privacyDefaultLocked = false;
72
+ private redactAll = false;
73
+ private keyCatalog: ReadonlySet<string> | undefined;
74
+
75
+ // ── Privacy ─────────────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * Choose how bare (unmarked) metadata values are treated.
79
+ *
80
+ * `'public'` — the OSS default — renders them. `'private'` redacts every
81
+ * bare value outside dev builds unless wrapped in `pub()`, so a forgotten
82
+ * wrapper hides data instead of leaking it. Apps handling PHI set
83
+ * `'private'` in their entry point.
84
+ *
85
+ * First call wins; later calls may only tighten (`'public'` → `'private'`).
86
+ * A request to loosen is ignored, not obeyed. Anything that is not exactly
87
+ * `'public'` or `'private'` — a JS caller, JSON config, an `any` — resolves
88
+ * to `'private'`: ambiguity must never resolve toward disclosure.
89
+ *
90
+ * `'private'` also makes the metadata key catalog mandatory — see
91
+ * {@link metadataKeyCatalog}.
92
+ */
93
+ privacyDefault(value: PrivacyDefault): this {
94
+ const normalized = normalizePrivacyDefault(value);
95
+ if (!this.privacyDefaultLocked) {
96
+ this.privacyDefaultValue = normalized;
97
+ this.privacyDefaultLocked = true;
98
+ } else if (normalized === 'private') {
99
+ this.privacyDefaultValue = 'private';
100
+ }
101
+ return this;
102
+ }
103
+
104
+ /** Redact every metadata value from here on, marked or not, dev or not.
105
+ * One-way: there is no call that undoes it. */
106
+ redactAllMetadata(): this {
107
+ this.redactAll = true;
108
+ return this;
109
+ }
110
+
111
+ /**
112
+ * Restrict metadata to an approved set of keys; anything else is dropped
113
+ * and counted. Rejecting computed keys is not enough on its own — a
114
+ * literal key like `patient123` is still PHI — so the catalog checks exact
115
+ * membership at runtime.
116
+ *
117
+ * Tighten-only: repeat calls intersect with the existing catalog rather
118
+ * than replacing it. Mandatory under a `'private'` privacy default, where
119
+ * an unconfigured catalog approves nothing and all metadata drops.
120
+ *
121
+ * Input is not trusted: a non-iterable, a throwing iterator, a non-string
122
+ * or malformed entry, or an over-long iterable all yield an empty catalog
123
+ * rather than an exception or a hung JS thread.
124
+ */
125
+ metadataKeyCatalog(keys: Iterable<string>): this {
126
+ const incoming = buildCatalog(keys);
127
+ if (this.keyCatalog === undefined) {
128
+ this.keyCatalog = incoming;
129
+ } else {
130
+ const intersection = new Set<string>();
131
+ for (const key of this.keyCatalog) {
132
+ if (incoming.has(key)) intersection.add(key);
133
+ }
134
+ this.keyCatalog = intersection;
135
+ }
136
+ return this;
137
+ }
138
+
139
+ private privacySettings(): PrivacySettings {
140
+ return {
141
+ privacyDefault: this.privacyDefaultValue,
142
+ redactAll: this.redactAll,
143
+ keyCatalog: this.keyCatalog,
144
+ };
145
+ }
146
+
147
+ // ── Fluent configuration ────────────────────────────────────────────────
148
+
149
+ /** Global minimum level; messages below it are discarded. Default 'debug'. */
150
+ minimumLevel(level: LogLevel): this {
151
+ this.globalMinimum = level;
152
+ return this;
153
+ }
154
+
155
+ /** Minimum level for a dot-hierarchical subsystem (`network` covers
156
+ * `network.api` unless the child sets its own). */
157
+ subsystem(name: string, level: LogLevel): this {
158
+ this.subsystemLevels.set(name, level);
159
+ return this;
160
+ }
161
+
162
+ resetSubsystem(name: string): this {
163
+ this.subsystemLevels.delete(name);
164
+ return this;
165
+ }
166
+
167
+ /** Toggle console printing. The console destination's test sink, when set,
168
+ * keeps receiving lines regardless. */
169
+ consoleLogging(enabled: boolean): this {
170
+ for (const { destination } of this.registrations) {
171
+ if (destination instanceof ConsoleDestination) {
172
+ destination.printEnabled = enabled;
173
+ }
174
+ }
175
+ return this;
176
+ }
177
+
178
+ /**
179
+ * Register a destination. A *different* destination already holding the
180
+ * same label is flushed, disposed, and replaced; re-adding an instance
181
+ * that is already registered is a no-op, because disposing and re-pushing
182
+ * the same object would leave a live destination in a closed state.
183
+ */
184
+ addDestination(destination: LogDestination): this {
185
+ // Identity first: an already-registered instance must not have its label
186
+ // getter touched again, or the capture-once invariant leaks right here.
187
+ if (this.registrations.some((r) => r.destination === destination)) {
188
+ return this;
189
+ }
190
+ let label: string;
191
+ try {
192
+ label = destination.label;
193
+ } catch {
194
+ warnFixed(
195
+ '[Logger] a destination was rejected: its label could not be read'
196
+ );
197
+ return this;
198
+ }
199
+ if (typeof label !== 'string') {
200
+ warnFixed(
201
+ '[Logger] a destination was rejected: its label is not a string'
202
+ );
203
+ return this;
204
+ }
205
+ this.removeDestination(label);
206
+ this.registrations.push({ destination, label });
207
+ return this;
208
+ }
209
+
210
+ removeDestination(label: string): this {
211
+ const index = this.registrations.findIndex((r) => r.label === label);
212
+ if (index >= 0) {
213
+ const removed = this.registrations.splice(index, 1)[0]!;
214
+ // Guarded separately: a failing flush must not skip dispose, or the
215
+ // removed destination leaks timers/handles it will never release.
216
+ try {
217
+ removed.destination.flush();
218
+ } catch {
219
+ // a failing flush must not break reconfiguration
220
+ }
221
+ try {
222
+ removed.destination.dispose();
223
+ } catch {
224
+ // a failing dispose must not break reconfiguration
225
+ }
226
+ }
227
+ this.failureCounts.delete(label);
228
+ this.disabledLabels.delete(label);
229
+ return this;
230
+ }
231
+
232
+ /** Drain every destination synchronously (bounded per destination). */
233
+ flush(deadlineMs = 2000): void {
234
+ for (const { destination } of this.registrations) {
235
+ try {
236
+ destination.flush(deadlineMs);
237
+ } catch {
238
+ // isolation: one failing flush must not stop the others
239
+ }
240
+ }
241
+ }
242
+
243
+ // ── Scopes & correlation ────────────────────────────────────────────────
244
+
245
+ /** Short-lived random correlation ID — the encouraged way to correlate.
246
+ * Never derive correlation IDs from patient/visit/record identifiers. */
247
+ newCorrelationId(): string {
248
+ return Math.random().toString(36).slice(2, 10);
249
+ }
250
+
251
+ /** A scoped logger tagging every message with a correlation ID, optional
252
+ * subsystem, and default metadata. Omit `correlation` to auto-generate. */
253
+ scoped(
254
+ correlation?: string,
255
+ subsystem?: string,
256
+ metadata?: LogMetadata
257
+ ): ScopedLogger {
258
+ return new ScopedLogger(
259
+ this,
260
+ correlation ?? this.newCorrelationId(),
261
+ subsystem,
262
+ metadata
263
+ );
264
+ }
265
+
266
+ // ── Logging ─────────────────────────────────────────────────────────────
267
+
268
+ log(message: LazyMessage, options?: LogOptions): void {
269
+ this.logMessage(message, options);
270
+ }
271
+
272
+ verbose(
273
+ message: LazyMessage,
274
+ metadata?: LogMetadata,
275
+ subsystem?: string
276
+ ): void {
277
+ this.logMessage(message, { level: 'verbose', metadata, subsystem });
278
+ }
279
+
280
+ debug(
281
+ message: LazyMessage,
282
+ metadata?: LogMetadata,
283
+ subsystem?: string
284
+ ): void {
285
+ this.logMessage(message, { level: 'debug', metadata, subsystem });
286
+ }
287
+
288
+ info(message: LazyMessage, metadata?: LogMetadata, subsystem?: string): void {
289
+ this.logMessage(message, { level: 'info', metadata, subsystem });
290
+ }
291
+
292
+ warning(
293
+ message: LazyMessage,
294
+ metadata?: LogMetadata,
295
+ subsystem?: string
296
+ ): void {
297
+ this.logMessage(message, { level: 'warning', metadata, subsystem });
298
+ }
299
+
300
+ error(
301
+ message: LazyMessage,
302
+ metadata?: LogMetadata,
303
+ subsystem?: string
304
+ ): void {
305
+ this.logMessage(message, { level: 'error', metadata, subsystem });
306
+ }
307
+
308
+ /** Marks incomplete work; highest severity so it always surfaces. */
309
+ todo(message: LazyMessage, metadata?: LogMetadata, subsystem?: string): void {
310
+ this.logMessage(message, { level: 'todo', metadata, subsystem });
311
+ }
312
+
313
+ /** Integration entry point (ScopedLogger, error handler, bridges). */
314
+ logMessage(message: LazyMessage, options?: LogOptions): void {
315
+ const level = options?.level ?? 'info';
316
+ const subsystem = options?.subsystem;
317
+
318
+ const effectiveMinimum =
319
+ (subsystem !== undefined
320
+ ? resolveSubsystemLevel(this.subsystemLevels, subsystem)
321
+ : undefined) ?? this.globalMinimum;
322
+ if (!levelAtLeast(level, effectiveMinimum)) return;
323
+
324
+ // Eligibility BEFORE message evaluation: a lazy thunk must not run when
325
+ // nothing will receive the entry. isEnabled/minimumLevel may be throwing
326
+ // getters, so each read is isolated and charged to the stable label.
327
+ const eligible: Registration[] = [];
328
+ for (const registration of this.registrations) {
329
+ const { destination, label } = registration;
330
+ if (this.disabledLabels.has(label)) continue;
331
+ try {
332
+ if (!destination.isEnabled) continue;
333
+ if (
334
+ destination.minimumLevel !== undefined &&
335
+ !levelAtLeast(level, destination.minimumLevel)
336
+ ) {
337
+ continue;
338
+ }
339
+ } catch {
340
+ this.noteFailure(label);
341
+ continue;
342
+ }
343
+ eligible.push(registration);
344
+ }
345
+ if (eligible.length === 0) return;
346
+
347
+ let text: string;
348
+ try {
349
+ text = typeof message === 'function' ? message() : message;
350
+ } catch {
351
+ // Payload-free by design: the thrown value could contain anything.
352
+ text = '[Logger] message thunk threw';
353
+ }
354
+
355
+ // Frozen: the same entry fans out to every destination, so a mutating
356
+ // destination must not change what its siblings receive. Every field is
357
+ // a primitive or a frozen null-prototype object, so freezing the entry
358
+ // is enough — nothing reachable from it is mutable.
359
+ const entry: LogEntry = Object.freeze({
360
+ timestamp: Date.now(),
361
+ level,
362
+ message: text,
363
+ metadata: redactMetadata(
364
+ options?.scopeMetadata,
365
+ options?.metadata,
366
+ this.privacySettings()
367
+ ),
368
+ correlation: options?.correlation,
369
+ subsystem,
370
+ });
371
+
372
+ for (const { destination, label } of eligible) {
373
+ try {
374
+ destination.write(entry);
375
+ this.failureCounts.delete(label);
376
+ } catch {
377
+ this.noteFailure(label);
378
+ }
379
+ }
380
+ }
381
+
382
+ /** Count a consecutive failure; disable the destination at the threshold.
383
+ * The diagnostic is fixed text — labels are caller-controlled, so including
384
+ * one would break the payload-free contract and invite log injection. */
385
+ private noteFailure(label: string): void {
386
+ const failures = (this.failureCounts.get(label) ?? 0) + 1;
387
+ this.failureCounts.set(label, failures);
388
+ if (failures >= MAX_CONSECUTIVE_FAILURES) {
389
+ this.disabledLabels.add(label);
390
+ warnFixed(
391
+ '[Logger] a destination was disabled after repeated write failures'
392
+ );
393
+ }
394
+ }
395
+
396
+ // Deliberately no reset/reconfigure hook. Such a method would be a public
397
+ // runtime path to loosen every privacy control on the `Log` singleton, and
398
+ // an `@internal` tag does not remove a method from a JS build. Tests
399
+ // construct their own `new Logger()` instead.
400
+ }
401
+
402
+ function defaultRegistrations(): Registration[] {
403
+ const console_ = new ConsoleDestination();
404
+ return [{ destination: console_, label: console_.label }];
405
+ }
406
+
407
+ /** Fixed-text diagnostic; never interpolates caller-controlled data. */
408
+ function warnFixed(message: string): void {
409
+ try {
410
+ console.warn(message);
411
+ } catch {
412
+ // nothing left to report to
413
+ }
414
+ }
415
+
416
+ /** Global shorthand, mirroring SwiftLogger's `Log`. */
417
+ export const Log = new Logger();
@@ -0,0 +1,94 @@
1
+ import type { LazyMessage, LogLevel, LogMetadata } from './types';
2
+ import type { Logger } from './Logger';
3
+ import { safeSnapshotMetadata } from './metadata';
4
+
5
+ /**
6
+ * A lightweight logger that tags every message with a correlation ID,
7
+ * optional default subsystem, and default metadata. Direct port of
8
+ * SwiftLogger's ScopedLogger: immutable, delegates all work to the parent
9
+ * Logger, nests.
10
+ *
11
+ * Metadata semantics: the scope's metadata merges into every message;
12
+ * call-site keys win on collision (a message reporting `state: 'failed'`
13
+ * must not be masked by the scope's `state: 'running'`). Child scopes merge
14
+ * into — never replace — the parent's metadata.
15
+ */
16
+ export class ScopedLogger {
17
+ /** Frozen snapshot taken at construction — mutating the object the caller
18
+ * passed in (or this field) after the fact must not change future logs,
19
+ * matching SwiftLogger's value semantics. */
20
+ readonly metadata?: Readonly<LogMetadata>;
21
+
22
+ constructor(
23
+ private readonly logger: Logger,
24
+ readonly correlation: string,
25
+ readonly subsystem?: string,
26
+ metadata?: LogMetadata
27
+ ) {
28
+ const snapshot = safeSnapshotMetadata(metadata);
29
+ this.metadata = snapshot ? Object.freeze(snapshot) : undefined;
30
+ }
31
+
32
+ log(
33
+ message: LazyMessage,
34
+ level: LogLevel = 'info',
35
+ metadata?: LogMetadata
36
+ ): void {
37
+ this.logger.logMessage(message, {
38
+ level,
39
+ subsystem: this.subsystem,
40
+ // Handed over unmerged: redaction settles precedence per key and
41
+ // validates the winner BEFORE reading it, so a getter behind a
42
+ // rejected key — or one the call site overrode — never runs. Merging
43
+ // here would read every value first and defeat that.
44
+ scopeMetadata: this.metadata,
45
+ metadata,
46
+ correlation: this.correlation,
47
+ });
48
+ }
49
+
50
+ verbose(message: LazyMessage, metadata?: LogMetadata): void {
51
+ this.log(message, 'verbose', metadata);
52
+ }
53
+
54
+ debug(message: LazyMessage, metadata?: LogMetadata): void {
55
+ this.log(message, 'debug', metadata);
56
+ }
57
+
58
+ info(message: LazyMessage, metadata?: LogMetadata): void {
59
+ this.log(message, 'info', metadata);
60
+ }
61
+
62
+ warning(message: LazyMessage, metadata?: LogMetadata): void {
63
+ this.log(message, 'warning', metadata);
64
+ }
65
+
66
+ error(message: LazyMessage, metadata?: LogMetadata): void {
67
+ this.log(message, 'error', metadata);
68
+ }
69
+
70
+ todo(message: LazyMessage, metadata?: LogMetadata): void {
71
+ this.log(message, 'todo', metadata);
72
+ }
73
+
74
+ /** Child scope: inherits this scope's subsystem unless overridden, merges
75
+ * metadata (child keys win). The merge happens at construction, like any
76
+ * scope snapshot — both sides are materialized here so later mutation of
77
+ * the caller's object cannot change what the child reports. */
78
+ scoped(
79
+ correlation: string,
80
+ subsystem?: string,
81
+ metadata?: LogMetadata
82
+ ): ScopedLogger {
83
+ const child = safeSnapshotMetadata(metadata);
84
+ const merged = this.metadata
85
+ ? Object.assign(Object.create(null) as LogMetadata, this.metadata, child)
86
+ : child;
87
+ return new ScopedLogger(
88
+ this.logger,
89
+ correlation,
90
+ subsystem ?? this.subsystem,
91
+ merged
92
+ );
93
+ }
94
+ }
package/src/config.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { LogLevel } from './types';
2
+
3
+ /**
4
+ * Resolves the effective minimum level for a subsystem by walking the
5
+ * dot-separated hierarchy: exact match, then each parent (`a.b.c` → `a.b` →
6
+ * `a`), then undefined so the caller falls back to the global minimum.
7
+ * Direct port of SwiftLogger's LoggerConfiguration.resolveSubsystemLevel.
8
+ *
9
+ * Single-threaded JS means no snapshot/locking machinery — a plain Map
10
+ * mutated by the fluent config methods is safe.
11
+ */
12
+ export function resolveSubsystemLevel(
13
+ subsystemLevels: ReadonlyMap<string, LogLevel>,
14
+ name: string
15
+ ): LogLevel | undefined {
16
+ if (subsystemLevels.size === 0) return undefined;
17
+ let current = name;
18
+ for (;;) {
19
+ const level = subsystemLevels.get(current);
20
+ if (level !== undefined) return level;
21
+ const dot = current.lastIndexOf('.');
22
+ if (dot < 0) return undefined;
23
+ current = current.slice(0, dot);
24
+ }
25
+ }