@speles7172/log-client
v0.1.1
Published
Structured logging for events, warnings and errors, with CloudWatch Logs Insights queries and statistics behind it.
Readme
@speles7172/log-client
Structured logging for events, warnings and errors — and the queries that read them back out of CloudWatch Logs Insights.
Three entry points, because one of them has to survive a browser bundle:
| Import | Runs in | Holds |
|---|---|---|
| @speles7172/log-client | Node | Sinks, the Insights query client, the browser ingest handler, the request-context store |
| @speles7172/log-client/core | anywhere | The record shape, the logger, redaction, the filter compiler |
| @speles7172/log-client/browser | a page | The batching logger that posts to your ingest endpoint |
Importing the root entry from a frontend pulls in the AWS SDK and
node:async_hooks. CI bundles core and browser for a browser on every run
so that the split stays real rather than aspirational.
Logging
import { createLogger, createStdoutSink } from '@speles7172/log-client';
const logger = createLogger({
source: 'admin-portal-api',
environment: process.env.STAGE,
sink: createStdoutSink(),
});
logger.event('user.login', { method: 'google' });
logger.warn('import.slow', 'took longer than usual', { table: 'users', durationMs: 4200 });
logger.error('import.failed', error, { table: 'users' });A record has a level (debug · info · warn · error) and an event
name. They answer different questions — "show me everything that went wrong" and
"show me every login" — and a console that conflates them can answer neither.
createStdoutSink is the right sink inside Lambda, ECS with the awslogs driver,
or anything else whose stdout is already piped to a log group: writing a line is
a complete delivery, with no credentials and no network call inside the request
being logged. createCloudWatchLogsSink exists for processes that are not wired
up that way.
The user
PLS-18 asks for the user on every record and for filtering by them. Rather than passing a logger down through every layer, put the identity in the ambient context once:
import { currentLogContext, runWithLogContext, updateLogContext } from '@speles7172/log-client';
const logger = createLogger({ source: 'api', sink, ambientContext: currentLogContext });
export const handler = (event) =>
runWithLogContext({ requestId: event.requestContext.requestId }, async () => {
const claims = event.requestContext.authorizer.jwt.claims;
updateLogContext({ user: { id: claims.sub, email: claims.email } });
// Everything logged from here down carries both, however deep.
});updateLogContext returns false outside a context — worth asserting on, since
the failure mode is records that are silently unattributed.
Redaction
Every record's fields go through a key-name deny list (password, token,
authorization, apiKey, …, matched regardless of case and separators) and are
depth-, size- and cycle-capped. It is a net, not a guarantee: a secret passed as
{ value: 'ghp_…' } sails through. Pass redact: createRedactor({ extraKeys })
to extend it, or redact: false to turn it off.
URLs
Redaction by key name cannot help when the key is innocent and the value is
the secret, which is exactly the shape of a page URL: an implicit-flow
#access_token=, a ?code= on the way back from a provider, a password-reset
link, a pre-signed download. So redactUrl strips those parameters — by name,
and by stem so that X-Amz-Signature is caught too — while keeping origin,
path, benign query parameters and hash routes intact.
It runs in every place a URL can reach a record: in the browser logger before
the batch is sent, again in createIngestHandler (the pass that matters, since
a page may be running an older version of the logger or nothing of ours at all),
and — via scrubText — inside free text.
Free text
Key-based redaction structurally cannot reach a message or a stack: there is no
key to match, only prose. And prose is where the commonest leak of all lives —
connect ECONNREFUSED postgres://api:hunter2@db:5432 is a real error message.
scrubText runs over the record message, over an error's message and stack down
the whole cause chain, and over string values inside fields. Its default
enforces one rule:
Anything labelled as a credential is redacted, wherever it appears. An unlabelled one is not.
Labelled means any of the three shapes a credential actually turns up in:
| Shape | Example |
|---|---|
| a URL parameter | https://api/x?token=… |
| a name=value or name: value pair | token=…, {"apiKey": "…"} |
| an auth scheme and its credential | Bearer …, Basic … |
All three consult the same name list, which is the point — redacting
?token= in a URL while leaving token= in the next sentence is not a policy,
it is an accident of where the code happens to look.
What it does not do is spot a bare high-entropy string with nothing naming
it. That needs either a catalogue of every vendor's token format, which rots, or
an entropy heuristic, which redacts request ids and base64 payloads and teaches
people to distrust the output. Either would trade an explicable guarantee for a
vague one. Sanitise before throwing, or pass your own scrubText; false turns
it off entirely.
It is careful in the other direction too: Bearer token expired is a sentence,
not a credential, and survives intact.
Metrics
createStdoutSink({ metrics: { namespace: 'Peles/AdminPortal' } });Wraps each record in an Embedded Metric Format envelope, so CloudWatch
extracts a count — dimensioned by source and level — while still storing the
line. That is what makes "alarm when errors spike" possible without a second
write path. Keep the dimensions few: every distinct combination is a separate
billed metric.
Flushing
Any batching sink must be flushed before a Lambda handler returns:
try {
return await handle(event);
} finally {
await logger.flush();
}The runtime freezes the process the moment the handler returns, and a pending timer does not run in a frozen process. This is the most common way batching loses data.
Reading logs back
import { createAwsInsightsApi, createLogQueryClient } from '@speles7172/log-client';
const logs = createLogQueryClient({
api: await createAwsInsightsApi({ region: 'us-east-1' }),
logGroupNames: ['/aws/lambda/admin-portal-api'],
});
const page = await logs.search({
start: '2026-08-18T00:00:00Z',
end: '2026-08-18T01:00:00Z',
levels: ['warn', 'error'],
userEmail: '[email protected]',
search: 'timeout',
});
const stats = await logs.statistics(filter, { include: ['level', 'time', 'user'] });Callers pass a filter, never query text. Insights has no bind parameters, so
every value ends up inside the query string; the compiler in core/insights.ts
is the only thing between a search box and a pipeline stage of the caller's
choosing, and it is pure and heavily tested for exactly that reason.
Insights is asynchronous. search() polls for you with a 25-second default
ceiling — under API Gateway's 29 — and returns status: 'timeout' with the query
id rather than losing the work. startSearch/pollSearch/cancelSearch expose
the two-call shape when the endpoint should hand the id back to the browser
instead.
Statistics run one Insights query per grouping, in parallel. Insights bills by
bytes scanned, so include is the knob that decides what the panel costs.
Browser logging
import { createBrowserLogger, installGlobalErrorCapture } from '@speles7172/log-client/browser';
const logger = createBrowserLogger({ endpoint: '/api/logs', source: 'admin-portal-web' });
installGlobalErrorCapture(logger);Records are batched, flushed on a timer, and flushed again through
navigator.sendBeacon when the page is hidden or closing — the only mechanism a
browser guarantees at that point. It costs the body being posted as
text/plain, which the ingest handler accepts.
On the server:
import { createIngestHandler, createStdoutSink } from '@speles7172/log-client';
const ingest = createIngestHandler({ sink: createStdoutSink(), source: 'admin-portal-web' });
export const handler = async (event) =>
ingest({
body: event.body,
// From the verified token, never from the body.
user: event.requestContext.authorizer.jwt.claims,
userAgent: event.headers['user-agent'],
});Everything in the body is a claim made by code the user can edit. The
handler replaces the identity with whoever the request authenticated as, forces
origin to browser, checks source against an allow-list, and disbelieves a
client clock more than five minutes out — keeping the claimed timestamp as a
field so a machine with a wrong clock stays diagnosable. It does not
authenticate or rate-limit; both belong to the endpoint.
Design notes
- A logging call never throws. Losing a log line is bad; taking down the
request that produced it is worse. Sink failures go to
onError, whose default is to swallow them, because the only tool for reporting them is the thing that just broke. - One JSON object per line. That is what makes Insights discover fields at all, and why nothing is ever concatenated into the payload by hand — an embedded newline splits the record and it silently stops being queryable.
- Nesting stays two levels deep. A field at depth five is a field the console cannot filter on.
