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