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

@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.