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

@12-apps/observability-frontend

v2.4.2

Published

Browser error reporting for a React SPA, on Sentry: deferred init behind a served DSN, the noise rules that keep a deploy from filing one issue per open tab, a PII scrub, a route error boundary and a self-check page that proves the chain is live in the de

Readme

@12-apps/observability-frontend

Browser error reporting on Sentry — reading what production actually said, on the side of the wire nobody can attach a debugger to.

It has a server-side counterpart, @12-apps/observability-backend. They are two packages rather than one because they share no code at all, only environment variables — one side is @sentry/node plus winston-transport, the other is @sentry/react plus React, and a single package would put a Winston transport in every SPA's dependency tree and React in every server's:

| package | contents | | --- | --- | | @12-apps/observability-backend | a Winston transport that forwards error/warn on @sentry/node, a PII scrub that redacts by key at any depth, and a flush for a deliberate exit | | @12-apps/observability-frontend | deferred init behind a served DSN on @sentry/react, the noise rules that keep a deploy from filing one issue per open tab, a stricter PII scrub, a route error boundary, a self-check page, and the Vite source-map upload plugin |

They are independent — either runs without the other — but they deliberately share SENTRY_ENVIRONMENT and SENTRY_RELEASE, so a browser event and the 500 behind it land in the same environment under the same build.

Both are host-agnostic: no fetch wrapper, no router assumptions, no product copy, no tenant vocabulary. Everything they cannot know — what a routine API error looks like, what a crashed page should render, where the config lives — arrives through a named seam the host fills in. See the seams.

They were extracted from a working application rather than designed in the abstract, so the reasoning below cites the failures they came from. What stays in the consuming repo is deployment fact rather than library behaviour: which Sentry project each app reports to, and how the upload token reaches docker build.

Entry points

| import | what it is | pulls in | |---|---|---| | @12-apps/observability-frontend | init, the pre-init buffer, context tags, beforeSend, reportRouteCrash / reportWarning | @sentry/react | | @12-apps/observability-frontend/react | createRouteErrorBoundary, createObservabilityPage | + React, react-router-dom | | @12-apps/observability-frontend/self-check | the self-check page itself | + @12-apps/ui | | @12-apps/observability-frontend/vite | the two build plugins: source-map upload, and emitting the worker reporter | @sentry/vite-plugin (build only) | | @12-apps/observability-frontend/service-worker | the worker reporter, as a classic script | nothing — self-contained IIFE |

That split is not cosmetic. The root entry is framework-free, so a worker or a non-React host can report without React arriving through a barrel it did not ask for. And the page is reachable only through its own subpath or the dynamic import() inside the route factory — naming it in the react barrel would pull a diagnostic nobody opens into every app's entry chunk.

Three of these ship in different formats, and none of it is preference. What LOADS the code decides what the code must be:

| entry | loaded by | so it ships as | |---|---|---| | . / /react / /self-check | the consumer's bundler | TypeScript source | | /vite | Node (Vite bundles a config but leaves bare specifiers external) | compiled ESM | | /service-worker | importScripts() | a classic script (IIFE) |

Node refuses to strip types below node_modules, and importScripts cannot load an ES module at all — so both of those would fail in every consumer, on the first build after publishing, while working perfectly as a workspace sibling where pnpm's symlink puts the realpath outside node_modules.

Wiring an app

1. Start it, before the first render, without awaiting it

// main.tsx
import { startObservability } from "@12-apps/observability-frontend";

void startObservability("storefront");

The two halves have opposite timing needs, and the single call gets both right. Installing the global handlers (window.onerror, unhandledrejection) is synchronous and must precede the first render, or a throw during boot is lost. Fetching the DSN is a network round-trip and must not gate first paint — an app that waits on error reporting to draw has made reporting the outage. So the handlers go in immediately and buffer into memory; the buffer drains once the SDK is up, or is dropped if reporting turns out to be off.

2. Configure the error boundary once, mount it everywhere

// route-error-boundary.tsx
import { createRouteErrorBoundary } from "@12-apps/observability-frontend/react";

export const RouteErrorBoundary = createRouteErrorBoundary({
  fallback: ({ error, reload }) => (
    <ErrorState message={error.message} onRetry={reload} />
  ),
});
// the layout, around the routed outlet
<RouteErrorBoundary resetKey={location.key}>
  <Outlet />
</RouteErrorBoundary>

Why the boundary is in an observability package at all. window.onerror never sees a render crash — React catches the throw and re-throws it out of band — so an app with global handlers but no boundary reports nothing for the one failure mode that blanks the screen. They are two halves of the same guarantee, and shipping them apart is how one ends up missing.

Two things to get right:

  • resetKey is the routed location. Without it the boundary latches: error state is component state, and React keeps rendering the fallback until something clears it, so every later navigation shows the failure of a page the user already left. It deliberately does not clear on an unrelated re-render — that would remount a deterministically-crashing page in a loop.
  • Build it at module scope. A boundary rebuilt per render is a new component type each time, so React unmounts and remounts everything below it whenever the parent renders.

The fallback gets reload and reset. Prefer reload: the usual cause of a crashed page is a chunk that stopped existing at the last deploy, and re-rendering the same tree cannot fetch a file that is gone.

3. Mount the self-check route

import { createObservabilityPage } from "@12-apps/observability-frontend/react";

const observabilityRoute = createObservabilityPage({
  boundary: (children) => (
    <RouteErrorBoundary resetKey="self-check">{children}</RouteErrorBoundary>
  ),
});

// …then interpolate it, do not render it:
<Routes>{observabilityRoute}</Routes>

It returns a <Route> rather than exporting a component because <Routes> reads its children with createRoutesFromChildren instead of rendering them — a component that returns a <Route> is ignored in silence.

Pass the app's own boundary. The page's render-crash button only proves something if the boundary a real page crash would hit is the one that catches it; a boundary the package supplied would test the package.

4. Upload source maps at build time

// vite.config.ts
import { sentrySourcemaps } from "@12-apps/observability-frontend/vite";

plugins: [react(), sentrySourcemaps({ project: "my-frontend" })],

Falsy — so Vite skips it — unless SENTRY_AUTH_TOKEN and SENTRY_ORG are both set, which keeps local and PR builds working with no secret. It uploads and then deletes the .map files, so the dist that reaches the web server has none.

5. Report from the service worker

A service worker is a separate global scope: the page's window.onerror, its unhandledrejection handler and its route boundary see nothing a worker throws. An app can have all of the above wired up and still be blind to the code that decides whether a deploy bricks an installed PWA.

Two lines in the worker, one plugin in the build:

// vite.config.ts
import { observabilityServiceWorkerAsset } from "@12-apps/observability-frontend/vite";

plugins: [react(), observabilityServiceWorkerAsset()],
// public/sw.js — the first two lines
importScripts("/observability-sw.js");
observability.installWorkerReporter({ app: "storefront" });

The plugin emits the reporter into dist for production and serves the same path from node_modules in development — a worker that 404s on its first line is a worker nobody can debug, and public/ is copied verbatim by the build, so writing it there would mean committing a build artifact.

That wires two global handlers: a throw in a handler arrives as error, and a rejected promise handed to waitUntil/respondWith arrives as unhandledrejection. Failures a worker swallows ON PURPOSE reach neither — the catch(() => undefined) that stops a failed install leaving a page with no worker at all — so report those by hand where they matter:

caches.open(SHELL_CACHE)
  .then((cache) => cache.add(SHELL_URL))
  .catch((error) => observability.reportWorkerError(error, { handler: "install" }));

Every event is tagged source: service-worker, because a worker failure and a render crash want different people looking.

What it will not report: a failed request. Offline is a worker's normal weather and every caching strategy is built around fetch rejecting, so Load failed / Failed to fetch / NetworkError… are dropped before any I/O. There is also a cap of 5 events per worker INSTANCE — a handler failing in a loop fires hundreds of times a second — and the slot is claimed before the first await, since a counter incremented after the send would let every call in a tick past the check and cap nothing at all.

Stacks arrive as text, not frames. A worker script is served verbatim and unminified, so sw.js:118 in the raw stack is already the answer; hand-writing a per-browser stack parser inside the code that reports failures is a second surface to get wrong.

The self-check page

Every app that mounts it gets /__observabilidade: a status panel reading the live SDK (initialised?, DSN with the key masked, environment, release) and three buttons, one per reporting path.

| button | path exercised | |---|---| | Quebrar no render | throw during render → boundary → reportRouteCrash | | Erro no handler | throw in a callback → window.onerror → global handler | | Enviar warning | reportWarning directly, no error involved |

Why a page and not another test. Every part of this pipeline is unit-tested and all of it can still be dead in production, because what breaks is never a component — it is the PAIRING of two of them, and each half looks correct alone. The case that motivated it: for two deploys one SPA uploaded its source maps to a sibling's Sentry project. Builds were green, uploads succeeded, the config endpoint answered 200, and symbolication was broken the whole time. Nothing in CI could have said so.

Read it like this: an empty DSN in the panel means the variable never reached the process and no button will send anything; otherwise the event should appear within seconds, and its stack must name a .tsx file and line. A minified frame means the maps are not paired with this release, which is the failure the page exists to surface.

It sits outside the auth gate deliberately — the moment you most want telemetry is when the app is broken, and "the app is broken" often includes login. That is safe: the ingest DSN is write-only and ships in every bundle, so anyone wanting to burn the quota can already POST to Sentry without this page. A 3s per-tab throttle keeps a leaning finger from becoming a flood, and every event it produces is tagged self-check so they filter and delete as a group.

The seams

Six, and each exists because getting it wrong is silent.

| seam | how the host fills it | what it costs to skip | |---|---|---| | app name | startObservability(app); typed as string here, narrow it to a union in the host | nothing, but the union is what makes a typo in main.tsx a compile error | | config endpoint | startObservability(app, { endpoint }); defaults to /api/observability-config | — | | noise classifiers | setErrorClassifiers({ isIgnorableResponse, isStaleChunk }) | the host's routine 4xx and its own chunk-loader errors become issues | | span text rule | setSpanTextScrubber(text => …), before startObservability | on a host whose paths name a tenant, every Web Vitals span carries the tenant in its page name and URL | | crash fallback | createRouteErrorBoundary({ fallback, onCrash }) | — | | self-check boundary + lazy wrapper | createObservabilityPage({ boundary, lazyComponent }) | the crash button tests a boundary no real page uses |

One line that is deliberately not a seam: the stale-chunk wordings. Chrome, Safari and Vite each phrase a dead chunk differently, and pushing that list across the host boundary means dropping one browser's noise while reporting another's — the same non-bug filed once per Safari user and never once per Chrome user. Browser wording is browser knowledge, so it stays here in noise.ts; the host's isStaleChunk stacks on top, for loaders that throw a typed error instead of a recognisable message.

It is OFF unless a DSN arrives

No DSN, no SDK, no network. Dev, CI and every test run stay offline — which is also what stops a suite filling the issue tracker with its own deliberate failures.

Why the DSN is served rather than baked in

A Vite SPA is a static bundle. import.meta.env is inlined at build time, so a VITE_SENTRY_DSN is frozen at docker build and a rotation becomes a rebuild — and with a bundle served by a web server with no Node process, there is nothing in production to read process.env on its behalf either.

So the browser asks GET /api/observability-config?app=<app> on boot. Reading env through a dynamic key on the server reports the runtime configuration rather than build-time-inlined empties. The endpoint is public and unauthenticated on purpose: an ingest DSN is write-only and ships in a bundle every visitor downloads, and it has to answer before a session exists, because the errors most worth catching are on the login screen and the first paint.

The cost is that the SDK starts a round-trip late, which the synchronous handlers plus the buffer pay for. The only window left uncovered is "the browser could not fetch the entry bundle at all", which no in-page reporter catches.

Field Web Vitals — only when the config serves a rate

The config endpoint may also answer tracesSampleRate, a number from 0 to 1. Absent means 0, so an older backend keeps errors on and performance off; a present but malformed value rejects the whole config rather than guessing.

With a rate above 0:

  • Every span leaves through beforeSendSpan. traceLifecycle: "stream" is a client option, not a per-integration one. Streamed spans never reach beforeSendTransaction, so that one hook, wrapped in withStreamedSpan, is where the PII rules below run for spans, as beforeSend is for errors. It is installed on the live client options together with the vitals, before the integrations that make spans, so the scrub is not on the critical path either.
  • The vitals integrations load after load. webVitalsIntegration and spanStreamingIntegration come in through a dynamic import once the page has loaded. Measuring the first paint must not delay it. The host's bundler has to keep that chunk lazy: a manualChunks rule that pulls every @sentry module into one eager chunk pulls these in too.
  • A span names a page three ways: its name, the sentry.transaction and sentry.segment.name attributes, and any *.url attribute a vital points at. URLs go through scrubUrl() first. Then the host's setSpanTextScrubber rule runs on the names and on each URL's PATH (never its origin). This package cannot know which path segment names a tenant, so it defaults to no rule.

Noise — what must never become an issue

A reporter that shouts on every deploy is muted in its first week, and a muted reporter still bills. All of it happens in one beforeSend, so it applies to events the SDK's own instrumentation raises as well as to hand-captured ones.

  1. Stale chunks after a deploy. Every hashed filename dies with the deploy that produced it, so every tab opened beforehand asks for a chunk that is gone. Dropped everywhere except at the route boundary: a host loader that reloads once has already swallowed the first failure, so a chunk error that reaches the boundary survived that recovery and is worth a look.
  2. A routine non-5xx answer. Whatever the host's fetch layer throws for a 400 looks exactly like a crash. isIgnorableResponse is how it says otherwise; a 5xx still passes.
  3. Code that is not ours. ResizeObserver loop …, the opaque cross-origin Script error. (no stack, no file — it groups everything unrelated under one heading), and stacks pointing into browser extensions.

PII

An SPA's exposure is ordinary rather than exceptional: a checkout screen holds the payer's name, e-mail, tax id and card fields in React state and in the DOM, within reach of any automatic breadcrumb. A URL is evidence by itself.

  1. scrub() redacts by KEY at any depth, normalising case and separators so taxId / tax_id / TaxID are one entry. By key rather than by value shape: a Brazilian CPF is eleven digits, and so are plenty of ids worth keeping. Order ids, charge ids and amounts survive on purpose — a scrub that eats them leaves every event undiagnosable.
  2. scrubUrl() keeps the path and drops the query string and fragment. The path is what makes an event triageable; the query string is where tokens and a ?next= e-mail end up.
  3. sendDefaultPii: false, held off regardless of DSN, so request bodies, cookies and IPs never ride along.
  4. No Session Replay. A replay of a checkout crash is a recording of someone's card. Turning it on is a separate decision that needs masking configured first.

setObservabilityContext takes a narrow object rather than a user, so the tags stay to the set that makes an event triageable without identifying anyone. It writes only the keys actually passed — independent components set context from different places, and if both wrote every key the later effect would silently overwrite the earlier one in an order neither controls.

Tests

pnpm --filter "@12-apps/observability-*" test

They run on jsdom with @sentry/react mocked, so nothing reaches the network.