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

stonedog-logs

v1.0.0

Published

One logging library for JavaScript and TypeScript projects: a level-filtered dispatcher, console/file/OpenTelemetry sinks, and credential redaction that a host extends with its own keys.

Readme

stonedog-logs

One logging library for JavaScript and TypeScript projects: a level-filtered dispatcher, console / file / CloudWatch / OpenTelemetry sinks, and credential redaction that a host extends with its own keys.

Apache-2.0. Node ≥ 20. ESM.

npm install stonedog-logs
import { initializeLogger, log } from "stonedog-logs";

await initializeLogger({
  consoleLogLevel: "INFO",
  serviceName: "my-app",
  // Fields THIS product considers sensitive, on top of the credential-shaped
  // defaults every consumer gets. See "Redaction" below.
  redactKeys: ["diagnosisCode", "memberNumber"],
});

log.info("checkout complete", { orderId: "o_123", token: "…" });
// → [INFO] checkout complete {"orderId":"o_123","token":"[REDACTED]"}

Entry points

| Specifier | What it is | | --- | --- | | stonedog-logs | Isomorphic. Picks Node or browser at runtime. | | stonedog-logs/node | Node sinks: console, file, CloudWatch Logs, OTLP over gRPC. | | stonedog-logs/browser | Browser sinks: console, OTLP over HTTP. | | stonedog-logs/core | The dispatcher, sinks, redaction and queue, with no platform code. |

These four replace four separate npm packages (hopper-logger, hopper-logger-shared, hopper-logger-node, hopper-logger-js). The internal three-way split survives as source directories and as these subpaths; what does not survive is four package.jsons that had to version-bump in lockstep for every change.

The browser never gets the gRPC exporter

exports["."] declares a browser condition, so a bundler resolving stonedog-logs is handed an entry that has no path to the Node implementation — not a dynamic one, not a two-hop one. The isomorphic entry reaches Node through await import(), and bundlers follow dynamic imports; without the condition, node:fs and @opentelemetry/exporter-logs-otlp-grpc would land in browser chunks.

This is measured, not asserted: scripts/verify-package.sh resolves the installed package with node --conditions=browser, walks the real module graph from whatever that returns, and fails if it can reach any Node-only specifier. It then re-resolves under the default condition and fails if that doesn't reach Node — otherwise the first check would pass just as happily against a broken walker.

Amazon CloudWatch Logs

import { initializeLogger, log, flushLogs } from "stonedog-logs/node";

await initializeLogger({
  consoleLogLevel: "INFO",
  serviceName: "hopperguard-web",
  cloudWatch: {
    projectName: "hopperguard",
    env: "prod",
    region: "us-west-2",
    // Omit `credentials` to use the SDK's default provider chain.
    credentials: { accessKeyId, secretAccessKey },
  },
});

log.info("checkout complete", { orderId: "o_123" });
await flushLogs(); // before the process exits

| | | | --- | --- | | Log group | /<projectName>/<env>/hopperguard/prod, /rozcards/prod | | Log stream | <serviceName>/<instance>hopperguard-web/9f2a1c04 | | IAM | logs:CreateLogStream and logs:PutLogEvents on the group | | Payload | One JSON object per record, so Logs Insights can address attributes.orderId |

One group per project per environment, one stream per instance. The group boundary is what makes a project's logs queryable in isolation, and separately what makes a per-project encryption key and a per-project retention policy expressible at all — a single shared group can express neither. The stream boundary is not cosmetic either: PutLogEvents is rate-limited per stream, so two containers sharing one throttle each other, and the API requires the events within a call to be chronologically ordered, which independent writers cannot guarantee about each other.

The AWS SDK is an optional peer, not a dependency

npm install @aws-sdk/client-cloudwatch-logs   # only if you use this sink

A consumer that never configures the sink installs none of it, and one that does almost certainly already has its own copy — two AWS SDKs in one process is a version-skew problem nobody wants delivered by a logger. The sink types the client structurally and reaches it through a dynamic import(), so this package compiles, type-checks and unit-tests with the peer absent, and a consumer that declined it gets a degradation callback rather than an ERR_MODULE_NOT_FOUND at process start.

What it guarantees under failure

A logging sink that can block or crash its host is worse than no sink, because it converts an incident in the observability stack into an incident in the product. Three rules, each covered by a test:

  • It never blocks. log() does no I/O. It formats, pushes, and returns; shipping happens on a timer, which is unref'd so it cannot hold a short-lived process open.
  • It never crashes the host. Every promise it creates is awaited inside a catch, including the ones a timer would otherwise discard. This is the interesting half: Node terminates the process on an unhandled rejection, so a dropped promise from putLogEvents kills the app — and a try/catch around the call sees nothing, because the call succeeds and returns a promise. Proved in the integration tier by a real child process with a wholly failing transport, asserted to exit 0, alongside a planted unattended rejection asserted to exit non-zero so the first check cannot pass vacuously.
  • It is bounded. The queue has a hard cap and drops the oldest records when it is hit, reporting the count through onDegraded rather than swallowing it. An unbounded queue in a logger is a memory leak that surfaces as an OOM in the product rather than as a logging fault. A batch that fails every retry goes to console and is not re-queued: re-queueing turns an outage at the log service into an outage in the app.
  • flush() is trustworthy. A caller arriving while a drain is already in flight joins it rather than resolving over it, and a drain ships the whole backlog rather than one batch per interval. Both are the shapes a mutual exclusion flag naturally produces, and both lose records silently: the first makes flush() a no-op whenever it races the timer, the second caps throughput at maxBatchRecords per interval so the queue overflows at volumes the API handles comfortably.

Batches respect the API's own limits — 10,000 events and 1 MiB per call, 256 KiB per event — because a batch that breaks one is rejected whole, so a single oversized record would otherwise take a thousand healthy ones with it. Oversized records are truncated on the way in rather than left for the service to reject.

Browser logs need a route, not a credential

stonedog-logs/browser has no CloudWatch sink and will not be given one. A browser has no AWS credentials and must not be issued any: a credential shipped to a browser is a credential published, and one scoped to logs:PutLogEvents still lets anybody who reads the bundle write unlimited billable data into your account and bury real records under noise.

Browser logs reach CloudWatch through the host application, not through this package: an authenticated, rate-limited ingest route (POST /api/logs) that forwards to a Node-side sink. That route is a public write endpoint and belongs to whichever app exposes it — it needs the app's own session authentication, its own per-identity rate limit, a payload size cap, and a bounded fan-in, none of which a logging library is in a position to provide or to police. Shipping it here would mean shipping a security-relevant endpoint whose defences depend entirely on integration decisions this package cannot see.

Redaction

Two layers, and the split is the point.

Defaults, which every consumer gets and nobody can switch off, are credential-shaped only: password, pin, token, accessToken, refreshToken, authorization, secret, and friends. Things whose disclosure is an incident in any product, in any industry.

Project keys travel with the project, via initializeLogger({ redactKeys }). Case-insensitive; it can only ever widen.

A logging library cannot know what a given product considers sensitive, and the package this one was extracted from tried anyway — it hard-coded one product's HIPAA field list, which failed in both directions at once. It over-redacted everywhere else (a signup that fails is debugged by looking at the name and email that failed, and those came back [REDACTED] with no sign anything had happened), and it under-redacted at home, because the next PHI field to be added was on no list and never would be. LEGACY_PROJECT_REDACT_KEYS is exported so a migrating consumer can diff its own list against what it used to get for free, and find out in a test rather than in a log file.

Log levels

TRACE(5) · DEBUG(7) · INFO(9) · WARN(13) · ERROR(17). A sink receives a record when the record's severity is at or above the sink's level.

An uninitialised logger never swallows an error. Records below ERROR are queued until initializeLogger drains them; ERROR and above go straight to console.error, redacted. Queueing assumes something will drain the queue — a long-lived server always does, a standalone script frequently never does, and then a failure exits with no output at all.

Configuration

Configuration is passed to initializeLogger. This package reads no environment variables — including for the CloudWatch sink, whose region, credentials, project and environment are all passed in by the host. That is a deliberate statement rather than an omission: neither did any of the four packages it replaces, and inventing an env-var surface during an extraction would ship untested behaviour under the cover of a refactor.

The STONEDOG_LOGS_* variables (with ROZ_LOGS_* honoured as a fallback for Raspberry Pi devices still carrying the old spelling) belong to the Python distribution, which is a separate codebase with its own version number. If this package ever grows an env-var layer, it must use those names with that fallback.

Test tiers

| Tier | What it does | Command | | --- | --- | --- | | Unit | Dispatch, redaction, level filtering, platform selection, the sink wiring of each entry. Two jest projects — node and jsdom — because neither environment can exercise the other's sinks. | npm run test:unit | | Integration | Packs the tarball, installs it into a bare project outside this repo, and drives every entry point through Node's real resolver — including --conditions=browser, and a file sink that writes a real file to a real disk. | npm run test:integration | | End-to-end | Not applicable, and stated rather than skipped. This is a library with no running surface: no server, no route, no page to point a browser at. The nearest honest e2e is "a real consumer installs it and it works", which is the integration tier. | — |

npm run gate runs type-check, lint, unit, build and integration in that order. CI runs the same and then prints the size of every input set, because a check that examined nothing exits 0 and reads as clean.

Migrating from hopper-logger*

Mostly a rename:

| Was | Now | | --- | --- | | hopper-logger | stonedog-logs | | hopper-logger-shared | stonedog-logs/core | | hopper-logger-node | stonedog-logs/node | | hopper-logger-js | stonedog-logs/browser |

Four things are not a rename:

  1. The browser initializeLogger is now async. It reached its OTel setup module with a bare require() inside a try/catch — which works under ts-jest, because the suite is transpiled to CommonJS, and cannot work in the shipped ESM artifact, where require is not defined. The ReferenceError was caught by the very try/catch meant to handle a missing exporter, so telemetry silently never started anywhere except in the tests proving it did. await import() is the fix, and it changes the signature. Callers that do not await keep the old behaviour and lose no records — early ones are queued.
  2. serviceName defaults to "app", not to a product name. The predecessor defaulted to "hopperguard", which silently mislabelled every other product's telemetry. Pass serviceName explicitly if you were relying on the old default.
  3. The isomorphic initializeLogger takes a typed union, not any. fileLogLocation is meaningless in a browser, and any accepted it silently.
  4. Version numbering restarts at 1.0.0. 1.149.0 / 1.213.0 / 1.308.0 were patch-per-PR artifacts of a monorepo release script, not semver.

The singleton state key is deliberately unchanged (Symbol.for( "HopperLoggerSingleton")), so a process holding both the old package and this one during a migration shares one set of sinks and one queue. Two states would mean records logged through one instance dispatched to the sinks configured on the other — which is to say, dropped.

What the merge deleted

scripts/add-esm-extensions.mjs, and the reason is worth recording because it looked like a permanent fixture. That script rewrote relative specifiers in the build output to carry .js, because three consumers read the same specifier and wanted three different things: Node reading dist/ demanded an extension, bundlers mapped the package at src/index.ts where ./config.js did not exist, and sibling packages compiled this source with their own tsconfig, where ./config.ts failed with TS5097. Extensionless was the only spelling all three accepted, so the extension had to be added after the fact.

Consolidating the four packages removes the sibling compilers and the src/ mapping. One consumer is left — Node, reading dist/ — so the source can just write .js and moduleResolution: nodenext does the rest.