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.
Maintainers
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-logsimport { 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 sinkA 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 isunref'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 fromputLogEventskills the app — and atry/catcharound 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
onDegradedrather 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 toconsoleand 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 makesflush()a no-op whenever it races the timer, the second caps throughput atmaxBatchRecordsper 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:
- The browser
initializeLoggeris nowasync. It reached its OTel setup module with a barerequire()inside atry/catch— which works under ts-jest, because the suite is transpiled to CommonJS, and cannot work in the shipped ESM artifact, whererequireis not defined. TheReferenceErrorwas caught by the verytry/catchmeant 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 notawaitkeep the old behaviour and lose no records — early ones are queued. serviceNamedefaults to"app", not to a product name. The predecessor defaulted to"hopperguard", which silently mislabelled every other product's telemetry. PassserviceNameexplicitly if you were relying on the old default.- The isomorphic
initializeLoggertakes a typed union, notany.fileLogLocationis meaningless in a browser, andanyaccepted it silently. - Version numbering restarts at
1.0.0.1.149.0/1.213.0/1.308.0were 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.
