@12-apps/observability-frontend

中文文档
2.4.2发布于 2026/10/01MITTypeScriptES Module构建来源已验证近 30 天下载 3.3万

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

原始使用文档
文档目录15 节
  1. Entry points
  2. Wiring an app
  3. 1. Start it, before the first render, without awaiting it
  4. 2. Configure the error boundary once, mount it everywhere
  5. 3. Mount the self-check route
  6. 4. Upload source maps at build time
  7. 5. Report from the service worker
  8. The self-check page
  9. The seams
  10. It is OFF unless a DSN arrives
  11. Why the DSN is served rather than baked in
  12. Field Web Vitals — only when the config serves a rate
  13. Noise — what must never become an issue
  14. PII
  15. Tests

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

packagecontents
@12-apps/observability-backenda 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-frontenddeferred 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

importwhat it ispulls in
@12-apps/observability-frontendinit, the pre-init buffer, context tags, beforeSend, reportRouteCrash / reportWarning@sentry/react
@12-apps/observability-frontend/reactcreateRouteErrorBoundary, createObservabilityPage+ React, react-router-dom
@12-apps/observability-frontend/self-checkthe self-check page itself+ @12-apps/ui
@12-apps/observability-frontend/vitethe two build plugins: source-map upload, and emitting the worker reporter@sentry/vite-plugin (build only)
@12-apps/observability-frontend/service-workerthe worker reporter, as a classic scriptnothing — 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:

entryloaded byso it ships as
. / /react / /self-checkthe consumer's bundlerTypeScript source
/viteNode (Vite bundles a config but leaves bare specifiers external)compiled ESM
/service-workerimportScripts()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.

buttonpath exercised
Quebrar no renderthrow during render → boundary → reportRouteCrash
Erro no handlerthrow in a callback → window.onerror → global handler
Enviar warningreportWarning 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.

seamhow the host fills itwhat it costs to skip
app namestartObservability(app); typed as string here, narrow it to a union in the hostnothing, but the union is what makes a typo in main.tsx a compile error
config endpointstartObservability(app, { endpoint }); defaults to /api/observability-config—
noise classifierssetErrorClassifiers({ isIgnorableResponse, isStaleChunk })the host's routine 4xx and its own chunk-loader errors become issues
span text rulesetSpanTextScrubber(text => …), before startObservabilityon a host whose paths name a tenant, every Web Vitals span carries the tenant in its page name and URL
crash fallbackcreateRouteErrorBoundary({ fallback, onCrash })—
self-check boundary + lazy wrappercreateObservabilityPage({ 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.

版本与发布记录

@12-apps/observability-frontend 共发布 21 个版本,首次发布于 2026/08/07;过去 12 个月发布了 21 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
2.4.2latest2026/10/011 个月前
2.4.12026/09/301 个月前
2.4.02026/09/301 个月前
2.3.02026/08/252 个月前
2.2.02026/08/232 个月前
2.1.02026/08/232 个月前

关于 @12-apps/observability-frontend 的常见问题

@12-apps/observability-frontend 是什么?

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

如何安装 @12-apps/observability-frontend?

在项目目录运行 npm install @12-apps/observability-frontend,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:react(>=19.0.0)、react-dom(>=19.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@12-apps/observability-frontend 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@12-apps/observability-frontend 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@12-apps/observability-frontend 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@12-apps/observability-frontend 可以商用吗?

@12-apps/observability-frontend 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@12-apps/observability-frontend 的下载量有多少?

根据 npm 官方下载统计,@12-apps/observability-frontend 在 2026/10/04 – 2026/11/02 这 30 天内被下载 32,985 次,日均约 1,100 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@12-apps/observability-frontend 还在维护吗?

最新版本 2.4.2 发布于 2026/10/01(1 个月前);至今共发布 21 个版本,其中过去 12 个月发布了 21 个;首次发布于 2026/08/07。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。