npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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-modules

react-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-modules is a peer, not a dependency. It is not installed for you, and it is where the createHybridObject runtime 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 install after installing. The pods carry the vendored Swift writer and the generated Nitro bindings; a stale Podfile.lock builds 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 your PATH.

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 objectcreateFileDestination() — 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 subsystem

Privacy

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

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