react-native-nitro-logger
v0.4.0
Published
Structured, privacy-tiered logging for React Native — TS core with a native Nitro file/os_log sink
Downloads
1,046
Maintainers
Readme
react-native-nitro-logger
Structured, privacy-tiered logging for React Native. A TypeScript core with native file and system-console sinks built on Nitro Modules.
- Privacy is a setting, not a habit. One call at startup decides whether an unwrapped metadata value renders or redacts. In the builds that ship, the strict profile is fail-closed: a forgotten wrapper hides data rather than leaking it.
- Designed around the things that actually go wrong. Rotation, gzip and retention; crash-tail recovery; detection of an externally deleted file, with a reopen into a fresh one; and a compliance purge that reports honestly when it could not finish. Where records cannot be saved they are accounted for rather than papered over: a full disk loses records and counts what it lost, and the partial write is rolled back to the last record boundary so the damage does not spread — an attempt, not a guarantee, since the rollback is itself I/O that can fail. An external deletion is detected and recovered from, but the unlinked file and anything written before detection are gone.
- New Architecture only, iOS and Android, no bridge fallback.
npm install react-native-nitro-logger react-native-nitro-modulesreact-native-nitro-modules is a required peer — this library is built on
Nitro Modules.
Setup
Autolinking does the wiring; what it cannot do is any of the four things below, and each of them fails in a way that looks like something else.
react-native-nitro-modulesis a peer, not a dependency. It is not installed for you, and it is where thecreateHybridObjectruntime lives.- New Architecture only. There is no bridge fallback: on an app with the old architecture the Hybrid Object is never registered, so the failure is at the first sink construction rather than at build time. React Native 0.76 and later default to New Architecture; below that, turn it on explicitly.
- iOS:
cd ios && pod installafter installing. The pods carry the vendored Swift writer and the generated Nitro bindings; a stalePodfile.lockbuilds an app whose binary does not contain them. - Android: JDK 17. The Gradle module targets it
(
JavaVersion.VERSION_17). Check what Gradle is actually using with./gradlew --version— the JVM it reports is the one that matters, and it is not always the one on yourPATH.
They fail at different times, which is the fastest way to tell them apart.
At build time. A JDK older than 17 fails the Android build, so there is no
app and nothing below applies. Which phase reports it varies — the Android
Gradle plugin often rejects the JVM while configuring, before this library is
compiled at all — so the reliable check is ./gradlew --version rather than
matching an error string.
At module resolution. A missing react-native-nitro-modules fails when the
import is resolved, so it reads as a bundler error about a package that is not
installed rather than as anything to do with logging.
At the first call that asks for the native object — createFileDestination()
— once the JavaScript has resolved and the app is running:
Cannot create an instance of HybridObject "FileSink" - It has not yet been
registered in the Nitro Modules HybridObjectRegistry! Suggestions:
...
- All registered HybridObjects: [...]That message means the native side is not registered, which narrows it to the
New Architecture or the pods. The bracketed list at the end is what separates
those two: empty means Nitro itself never initialised, so the app is on the old
architecture; entries but no FileSink means Nitro is fine and this library's
native code did not build in, which on iOS is a pod install that has not been
run since it was added.
Quick start
import {
Log,
ConsoleDestination,
createFileDestination,
} from 'react-native-nitro-logger';
Log.addDestination(new ConsoleDestination());
Log.addDestination(createFileDestination());
Log.info('app started');
Log.warning('retrying upload', { attempt: 2, statusCode: 503 });A scoped logger tags every line with a correlation ID, so one request's lines can be picked out of a busy file. Omit the correlation argument and one is generated for you — which is also what the lint rules want, since a correlation ID must never be derived from an identifier you already have:
const scope = Log.scoped(undefined, 'checkout', { orderKind: 'subscription' });
scope.info('payment authorised');
scope.error('capture failed', { statusCode: 502 });Levels
verbose · debug · info · warning · error · todo
Log.minimumLevel('info'); // globally
Log.subsystem('networking', 'debug'); // and per subsystemPrivacy
The default profile renders metadata values and is fine for open-source and general application use. Apps handling regulated data should switch to the strict one in their entry point, before anything logs:
import {
Log,
pub,
priv,
ERROR_METADATA_KEYS,
} from 'react-native-nitro-logger';
Log.privacyDefault('private');
Log.metadataKeyCatalog([
...ERROR_METADATA_KEYS, // the crash handler's own six — see below
'requestId',
'statusCode',
'durationMs',
]);
function onRequestFinished(id: string) {
Log.info('request finished', {
requestId: pub(id), // rendered
statusCode: 200, // redacted — unwrapped, and the default is private
});
}That is one metadataKeyCatalog call listing every key the app logs under, and
it is meant to stay one: calls intersect rather than replace, so a second
one naming different keys approves the overlap of the two, and a single
malformed key approves nothing at all. Under 'private' either mistake shows
up only as metadata that has quietly gone — a development build warns, and
docs/PRIVACY.md has the rest.
In a release build (__DEV__ false):
| | 'public' (default) | 'private' |
| --- | --- | --- |
| bare value | rendered | <private> |
| pub(v) | rendered | rendered |
| priv(v) | <private> | <private> |
A debug build renders private payloads in the clear, to every destination including the file — so the fail-closed behaviour above is a property of the builds that ship, not of every build. A build where reveal is possible is a build for synthetic data only.
privacyDefault is first-set-wins and tighten-only, so a dependency cannot
loosen your setting.
Message text, metadata keys, subsystems and correlation IDs are public by contract — they are never redacted at runtime. The bundled ESLint plugin constrains them at build time instead, but only once you enable it: installing this package ships the rules, it does not apply them.
// eslint.config.mjs
import nitroLogger from 'react-native-nitro-logger/eslint-plugin';
export default [nitroLogger.configs.strictTypeScript];strictTypeScript covers .ts, .tsx and JavaScript, so it is the only
entry a React Native app needs — pick one config, not both. It needs
@typescript-eslint/parser (an optional peer, no version constraint), which
most RN apps already have via @react-native/eslint-config.
Used on their own, configs.strict and configs.recommended lint
JavaScript only. A flat config with no files key applies to ESLint's
default set — .js, .mjs, .cjs — so on their own they never bring .ts
or .tsx into the linted set, and eslint . exits 0 without a word. If some
other entry in your config already matches TypeScript and supplies a parser,
these rules do run there as well — that is incidental composition, not
something to rely on, and it is exactly why this repository's own CI stayed
green while the published config was inert. It holds only while that other
entry keeps selecting TypeScript and supplying a compatible parser. Reach for
the bare configs if your sources are JavaScript, or if you have Flow-annotated
.js that the TypeScript parser would reject, in which case compose strict
with your own parser.
Read docs/PRIVACY.md before using this in an app that handles regulated data. It covers what the contract does and does not promise, the approved-key catalog, and the compliance boundary — in short, a build where reveal is possible is a build for synthetic data only.
Upgrading to 0.4.0
Ship it as a native release. 0.4.0 JavaScript over a 0.3.x binary is not
OTA-safe. Batches now cross the bridge as UTF-8 bytes (ArrayBuffer)
instead of a UTF-16 String; a 0.3.x binary rejects every batch at the
bridge while the app runs on looking healthy — nothing throws at a logging
call site, and the only runtime signals are flush() returning
durable: false with climbing unreported-loss counters. The changelog has
the measured failure shape on both platforms.
One compiler-pointed change for code in this repo's orbit:
| Was | Now |
| --- | --- |
| a FileSinkLike.appendBatch(batch: string, …) | appendBatch(batch: ArrayBuffer, …) — UTF-8 bytes |
Everything else — every logging call, destination option, formatter and file on disk — is unchanged. Log files written by 0.3.x are read, rotated and collected by 0.4.0 exactly as before.
Upgrading to 0.3.0
Four breaking changes, each of which the compiler points at:
| Was | Now |
| --- | --- |
| scope.log(msg, 'error', meta) | scope.log(msg, { level: 'error', metadata: meta }) |
| LogOptions.scopeMetadata | gone from the public type; set it by using a scope |
| import { createFileSink } from 'react-native-nitro-logger' | …from 'react-native-nitro-logger/unstable' |
| mutating a CollectOutcome field | the six spec result types are readonly |
The scope's six level methods (scope.info(msg, meta) and siblings) did not
change. Most callers of createFileSink want createFileDestination()
instead, which is a root export and does both steps.
/unstable needs one line of Metro config on React Native 0.78
That third row is the only change that is not purely an import edit, and only
at the bottom of the supported range. react-native-nitro-logger/unstable is a
subpath export, and Metro resolves subpath exports only when
unstable_enablePackageExports is on:
| Metro | Ships with | Default | …/unstable resolves |
| --- | --- | --- | --- |
| 0.81 | React Native 0.78 | false | no — Unable to resolve module |
| 0.82+ | React Native ≥ 0.79 | true | yes |
So on React Native 0.78 only, a project that imports /unstable sets the
flag in its existing metro.config.js. Merged into the stock 0.78 template,
which is what npx @react-native-community/cli init writes:
// metro.config.js
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const config = {
resolver: { unstable_enablePackageExports: true },
};
module.exports = mergeConfig(getDefaultConfig(__dirname), config);Add the property to whatever resolver object your config already has — do not
replace the file. getDefaultConfig is what supplies React Native's own
transformer, asset and resolver settings, and a metro.config.js that exports
a bare object without it will bundle something subtly wrong rather than fail
loudly.
Measured rather than inferred, and the two rows are not backed by the same thing.
The 0.81 row is a CI gate. scripts/check-metro-resolution.sh installs
React Native 0.78.0, React 19.0.0, Metro 0.81 and the packed tarball, then
bundles each entry point twice — once under the stock template
metro.config.js and once under the config above. It requires the root to
bundle both times, requires /unstable to fail under the stock config naming
that specifier, and requires the snippet above to fix it. So both the problem
and the workaround are executed on every run. min-rn-ios and min-rn-android
stay stock and import only the root entry point, because their value is in
standing for an unmodified consumer app.
The 0.82+ row is not. Its default is read out of
metro-config/src/defaults/index.js at 0.82 and 0.83, plus the fact that
neither @react-native/[email protected] nor @0.79.0 mentions the option,
so a stock app gets Metro's own default either way; the resolution behaviour
was bundled once locally on Metro 0.82, not in CI. Nothing re-checks it, so
read it as accurate when written rather than as enforced.
The root entry point resolved under every combination tried — this affects
/unstable and nothing else, so a project that only ever imports from
react-native-nitro-logger never meets any of it.
Writing to a file
const file = createFileDestination({
rotation: {
maxFileSizeBytes: 10 * 1024 * 1024,
maxArchivedFilesCount: 5,
compressArchives: true,
maxTotalLogBytes: 50 * 1024 * 1024,
},
});
Log.addDestination(file);By default logs live in app-private storage — noBackupFilesDir on Android,
Library/Logs on iOS. Owner-only modes are applied to every artifact; where the
platform refuses, the sink reports a protection degradation and keeps logging
rather than failing shut. Passing your own path keeps the modes, but Android's
backup exclusion comes from that default directory rather than from anything set
on the files, so it does not travel with them —
docs/PRIVACY.md has the detail.
file.getLogFilePaths(); // the individual files, if you would rather send those
// One gzip bundle of the whole log, for a consent-gated support upload.
// `gunzip` on it gives chronological JSON Lines. `maxTotalBytes` is required:
// how much of a log leaves the device is your call, not this library's.
const bundle = file.collectForSupport({ maxTotalBytes: 5 * 1024 * 1024 });
if (bundle.complete && bundle.path !== '') {
// Upload `bundle.path`. Nothing is transmitted or encrypted for you.
// Then delete it: collect -> upload -> delete. A bundle left behind is a
// gzipped copy of the whole log that retention never reclaims, because the
// sweep deliberately keeps a finished one in case you are still uploading it.
// Delete before disposing the destination — a released handle no longer knows
// whether that bundle is still its own, so it refuses rather than guess.
file.deleteSupportBundle();
}
const outcome = file.purge(5000); // the compliance purge
if (!outcome.durable) {
// Something survived, or the deadline blew. The destination stays fenced
// until an explicit retry, so nothing is written into a pending deletion.
}purge is synchronous and deadline-bounded. It reports durable (every
pre-purge artifact is gone) separately from rebound (the destination is
writable again), because a complete deletion can still be followed by a failed
reopen, and a caller that resumes on durable alone would write into a
destination with nowhere to put anything. A purge that rebinds opens a fresh,
empty active file — durable is a statement about the data that was there, not
a promise that the directory is left empty.
It clears the file sink's artifacts and nothing else. Anything already
handed to os_log or logcat is outside this library's reach.
Logging to os_log and logcat
NativeConsoleDestination writes into the platform log stream — os_log on iOS,
logcat on Android — so your JavaScript lines interleave with native ones in
Console.app, Xcode, and adb logcat.
import {
Log,
createNativeConsoleDestination,
} from 'react-native-nitro-logger';
Log.addDestination(
createNativeConsoleDestination({
subsystem: 'com.example.app',
category: 'network',
})
);Unlike ConsoleDestination, this one batches: a drain crosses the bridge once
with two parallel primitive arrays rather than once per entry.
It is best-effort by design, and that is the important thing to know about
it. There is no backpressure and no loss accounting against a disk, because
os_log accepts what it is given and never blocks. What it does account for is
its own buffer — dropped() returns the entries lost at the ceiling or to a
sink that threw, because a diagnostic channel that quietly loses records is
worse than one that admits it. After three consecutive native failures the
destination reports isEnabled === false and the logger stops formatting for
it, so a dead sink stops costing render work as well as bridge calls.
The durable copy is FileDestination's. Nothing here is a system of record,
which is what makes best-effort the right posture.
| Option | Default | Notes |
| --- | --- | --- |
| subsystem | the bundle's, chosen natively | Reverse-DNS, as os_log expects. Empty is legal and produces a logger nobody can find. |
| category | 'log' | Becomes the logcat tag on Android. |
| formatter | DefaultFormatter | os_log wants a line, not a JSON record. |
| label | 'native-console' | Registration key for removeDestination. |
| minimumLevel | inherited | Per-destination floor. |
| batchSize | 64 | Entries per bridge crossing. |
| flushIntervalMs | 100 | Idle coalescing window. |
| maxPendingEntries | 1000 | Buffer ceiling; oldest survive, newest are dropped. |
Long lines are split rather than silently cut off — the two platforms have genuinely different limits, and docs/PARITY.md records both the chunk sizes and why the split boundary differs between them.
Crash handling and backgrounding
import {
installErrorHandler,
installRejectionHandler,
flushOnBackground,
} from 'react-native-nitro-logger';
const uninstallHandler = installErrorHandler();
const uninstallRejections = installRejectionHandler();
const uninstallFlush = flushOnBackground();The error handler logs uncaught errors with the message dropped outside dev, the class name reduced to a built-in or a fixed token, and stack frames reduced to a position in a file whose name was already known. It flushes on fatal errors, then chains to whatever handler was installed before it. All three functions return idempotent uninstall handles.
It logs under six metadata keys of its own, exported as ERROR_METADATA_KEYS.
Under privacyDefault('private') those keys go through the same catalog as
yours, so a catalog that does not list them leaves crash reports arriving with
their metadata stripped — which is why the privacy snippet above spreads the
constant rather than transcribing the six. Spreading also survives a key being
added in a later version; a hand-written list would start dropping it without
saying so.
The rejection handler does the same for unhandled promise rejections, and this
one is worth installing even if you think you have it covered: React Native
tracks rejections in development and not at all in a release build, so an
async function that throws with nobody awaiting it is silent in exactly the
builds that ship. It does not flush — nothing is dying — and it logs a second
entry, at info, when a rejection reported unhandled turns out to be handled
after all, so the log takes back what a timer made it say too early.
Its keys are REJECTION_METADATA_KEYS, the constant above's twin, and spreading
both into one catalog is not a mistake: five of the six names are shared, and
the sixth is rejectionId, which joins the two entries about one rejection.
Output format
JsonLinesFormatter is the default for files — one JSON object per line, with
a framing guarantee the native crash-tail recovery depends on. It is asserted
byte-identical to SwiftLogger's JSONLogFormatter over a
generated corpus, against the specific revision
docs/PARITY.md pins.
{"timestamp":"2026-07-28T10:15:00.123Z","level":"INFO","message":"app started"}DefaultFormatter is the human-readable one used by the console destination.
Custom formatters implement LogFormatter; declare framing: 'line' to opt
into crash-tail trimming.
Compatibility
| | Supported | Verified |
| --- | --- | --- |
| iOS | React Native ≥ 0.78 | min-rn-ios 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 |
| Android | React Native ≥ 0.78, minSdk 24 | min-rn-android does the same into a pristine 0.78 app, builds it Release for the emulator's ABI, launches it on API 34 and reads the verdict off the ReactNativeJS logcat tag |
New Architecture only. The compatibility claims are split per platform on purpose — see docs/PARITY.md for what backs each one, and for where the two native writers genuinely differ.
Read the "Verified" column as the whole of the claim. "Supported" is a statement of intent; only the right-hand column is a statement about something that ran, and both rows now point at a job that installs this package the way a consumer would — from a packed tarball into an app generated from the community template — rather than at a build of the example.
The one difference between them is how the verdict leaves the app: iOS reads a
file the library itself wrote, Android reads what the app said about itself over
logcat. Reading the file on Android would need run-as and therefore a
debuggable build, and a debug build is not what the job exists to verify — R8,
the bundled JavaScript and the packaged .so set are precisely what differ in
release. docs/PARITY.md has the
rest, including where the two native writers genuinely differ.
Threading and builds
Every public method on Logger, ScopedLogger and the destinations is
synchronous and safe to call from any thread. What is not synchronous is the
I/O: Log.info(…) renders the entry, hands it to each destination and returns,
and the file destination buffers it. Records reach the disk when the buffer
fills, when a timer expires, or when you call flush(deadlineMs) — which is the
only call that waits for them.
That matters in three places:
- Before the process ends.
flushOnBackground()covers the ordinary backgrounding case. A crash is covered by the crash-tail recovery on the next open, not by a flush, because there is nothing left to run one. - Around
purge(). It is synchronous and deadline-bounded on purpose: a compliance deletion that returned before finishing would be worth nothing. - In a debug build. The reveal branch is
__DEV__-gated, so a debug build writes private payloads in the clear to every destination — including the file, where they outlive the session. docs/PRIVACY.md is the contract; the short version is that a build where reveal is possible is a build for synthetic data only.
Documentation
- docs/API.md — every export, grouped by what it is for
- docs/PRIVACY.md — the privacy contract and compliance boundary
- docs/PARITY.md — parity with SwiftLogger, and between the two native writers
- eslint-plugin/README.md — what the lint rules protect, and what they do not
Contributing
Absolute links, because these files are not in the npm tarball — they are
repository documents, and a relative link to them dies for anyone reading this
README inside node_modules.
License
MIT
