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

@xeonr/forest

v0.4.1

Published

Client SDK for Forest — send logs, metrics and analytics events from anything that runs JavaScript.

Readme

@xeonr/forest

Send logs, metrics and analytics events to Forest from anything that runs JavaScript — browsers, Node, Deno, Bun, React Native, Cloudflare Workers.

Zero dependencies. One class.

import { Forest } from "@xeonr/forest";

const forest = new Forest({ url: "https://forest.prod.wtf", token });

forest.info("user signed in");
forest.error(new Error("upload failed"));
forest.metric("fps", 59.5);
forest.event("checkout_completed", { plan: "pro" });

That's the whole API. Records are queued and sent in batches in the background; nothing blocks and nothing throws — a telemetry client that can break the application it observes is worse than no telemetry. Delivery problems go to onError.

Getting a token

A device never holds your API key. Your backend holds it and hands out short-lived per-device tokens:

// server-side only
import { mintToken } from "@xeonr/forest/server";

app.get("/telemetry-token", async (req, res) => {
  const token = await mintToken({
    apiKey: process.env.FOREST_API_KEY!,   // fk_<id>_<secret>
    clientId: req.user.deviceId,            // your own id for this device
    ttlSeconds: 3600,
    attrs: { plan: req.user.plan },         // stamped on every record
  });
  res.json({ token });
});

Minting lives at @xeonr/forest/server — a separate entry point so it can never be pulled into a browser bundle by accident. Never ship your API key to a device.

Tokens expire, so pass a function and the client refreshes on its own:

const forest = new Forest({
  url: "https://forest.prod.wtf",
  token: async () => (await fetch("/telemetry-token").then((r) => r.json())).token,
});

The token is a plain HS256 JWT with the key id in kid, so any language with an HMAC can mint one. mintToken just saves you reaching for a JWT library.

Options

| Option | Default | | |---|---|---| | url | — | Your forest host. Required. | | token | — | A token, or a function returning one. Required. | | attrs | — | Merged into every record; per-record attrs win. | | logger | — | Sent as the logger on every log line. | | release | — | Your app's release; sent as service.version on every record. | | batchSize | 100 | Flush once this many records are queued. | | flushInterval | 5000 | Flush at least this often (ms). 0 disables. | | maxQueue | 10000 | Queue cap; past it the oldest are dropped. | | retries | 3 | Attempts per batch. | | onError | — | Delivery failures and dropped records. | | onResponse | — | The server's reply, including dropped field names. | | fetch | globalThis.fetch | Override for tests or unusual runtimes. |

Custom fields

Attributes must be declared on the project before they can be filtered on. Anything undeclared is dropped server-side and named in the response — sending it is never an error, so shipping a new field before declaring it will not break your fleet. To see what is being dropped:

new Forest({
  url, token,
  onResponse: (r) => {
    if (r.droppedFields) console.warn("forest dropped:", r.droppedFieldNames);
  },
});

forest.error(err) puts the stack in the log body, the way console.error(err) does, so it survives on forest whatever is declared. It also sends the OpenTelemetry exception attributes (see below), which forest drops unless declared — expect exception.* in droppedFieldNames there.

Errors

forest.error(err);
forest.error(err, {
  release: "1.4.2",       // service.version (defaults to the client's `release`)
  route: "/player",       // url.path — the route or screen
  sessionId: playbackId,  // session.id — stitches the record to a session
  attrs: { engine: "hls" },
});
forest.error("upload failed", { code: 500 });  // the attrs form still works

An Error becomes a log record at level error whose attrs carry exception.type (the error's name, or its class when a subclass never set one), exception.message and exception.stacktrace; the options become service.version, url.path and session.id. That is the shape Lantern's Issues screen groups on, so device and native crashes land next to browser and server errors. The telemetry tier (below) treats all of those keys as built-in, so they need no declaration there.

The second argument is read as options only when every key in it is release, route, sessionId or attrs; anything else is plain attrs, as before options existed. So { route: "/a", code: 1 } is attrs, while a lone { route: "/a" } is the route option.

There is no session state in the client: pass sessionId per error, and put "session.id" in the attrs of anything else that belongs to the session.

Endpoint

url defaults to https://in.prod.wtf (since 0.4.1): Lantern's client ingest tier, which accepts the same /v1/ingest batch and the same device tokens unchanged, and forwards each batch of a migrating project on to forest as well. https://forest.prod.wtf and https://telemetry.xeonr.io route to the same tier and keep working; the name is not a telemetry. host because those are on browser block lists and home DNS filters (Pi-hole, NextDNS), where devices run.

new Forest({ token, release }); // → https://in.prod.wtf

Leave url unset for a project that exists in Lantern; its tokens only verify there once it does.

Catching crashes

const stop = forest.captureErrors();  // uncaught errors + unhandled rejections

Opt-in on purpose: installing global handlers from a constructor is the kind of side effect that is miserable to debug when you did not know it happened. Call the returned function to remove them.

Shutting down

await forest.flush();   // send what is queued
await forest.close();   // flush and stop the background timer

In browsers the client also flushes on pagehide and when the tab is hidden, as a keepalive fetch so the batch survives the page going away. That send carries the token in the query string, a text/plain body and no credentials, so it needs no preflight and passes forest's wildcard CORS policy from any origin; sendBeacon (which always sends credentials, and is therefore refused cross-origin) is only the fallback where fetch is missing. In Node the background timer is unref'd, so it never keeps a process alive.

Behaviour worth knowing

  • Retries are exponential with jitter, and honour Retry-After on a 429. A 4xx that is not 429 is not retried — a bad token does not fix itself.
  • The queue is capped. A long offline stretch drops the oldest records rather than growing until the tab or process dies.
  • Batches are all-or-nothing. A rejected batch is not partially applied.