@misakstvanu/prism-browser
v1.0.1
Published
Browser SDK for Prism: uncaught errors, console lines, logs, breadcrumbs and page views, posted to the Laravel backend the misakstvanu/prism Composer client registers.
Readme
@misakstvanu/prism-browser
The browser SDK for Prism. It captures uncaught errors,
unhandled promise rejections, console lines, log lines you write, breadcrumbs and page views (with
Web Vitals) in the page, and posts them to the route the
misakstvanu/prism Composer client registers
in your Laravel application. They land on the same Errors, Logs and Requests screens as your
server-side telemetry, filterable by runtime, and a page's error sits beside the backend request it
made — same trace, one waterfall.
There is no browser key and no direct line to the workspace: the page reports to your own
backend, which knows who is signed in, what the client IP is and what your prism.scrub list
says, and forwards the report through the pipeline every other signal already travels. That is the
whole shape of the feature, and it is why the setup below has a Composer step in it.
- ~9 kB gzipped, no dependencies loaded up front (
web-vitalsisimport()ed only when page views are on), ESM with tree-shakeable adapters, plus an IIFE build for a<script>tag. - Never throws into the page. Every public call is a no-op before
init(), afterclose()and under SSR; a failure inside the SDK reachesconsole.debugunderdebug: trueand nothing else. - Vue 3 and React adapters that report component errors with the component's name, and name the page after the route.
Requirements
misakstvanu/prism2.1 or later in the Laravel application the page talks to, enabled (PRISM_ENABLED, on by default) withPRISM_BROWSER_ENABLEDleft on (also the default).- A browser with
fetch,Promise,URLandcrypto— every evergreen browser. The SDK makes no attempt to run on Internet Explorer. - For the Vue adapter, Vue ≥ 3.3; for the React adapter, React ≥ 18. Both are optional peers — install neither if you use neither.
Quickstart
Three steps and the next JavaScript error is in the console.
1. Update the Composer client. The endpoint the SDK posts to ships with misakstvanu/prism
from 2.1, registered automatically at POST /_prism/browser — there is no route to add and no
controller to write:
composer update misakstvanu/prism
php artisan prism:checkprism:check prints a browser line naming the exact address the router will answer and who may
post to it. If it reads anything other than POST /_prism/browser (enabled, same-origin), fix that
before going further — from the page's side a 404 here is indistinguishable from a wrong URL.
2. Install the package:
npm install @misakstvanu/prism-browser
# or
yarn add @misakstvanu/prism-browser3. Initialise it once, as early as your bundle runs:
import Prism from '@misakstvanu/prism-browser';
Prism.init({
release: import.meta.env.VITE_APP_VERSION, // optional, but what makes "since which deploy" answerable
});That is the whole install. With no other option set, the SDK reports uncaught errors and unhandled
rejections with the last 30 breadcrumbs (clicks, navigations, console lines, HTTP calls), captures
console.warn / console.error as log lines, and adds a traceparent header to the page's
same-origin fetch / XHR calls so a backend request continues the page's trace. Page views and
HTTP-failure log lines are opt-in — capture: 'all' turns everything on.
Verify it: open the devtools console, run throw new Error('hello from the browser'), and within
five seconds a Browser group appears on the workspace's Errors screen. Its detail shows the page
URL, the viewport, the release and the trail of breadcrumbs that led up to it.
Without a bundler
The IIFE build exposes the same API on window.Prism:
<script src="/vendor/prism-browser/prism.iife.js"></script>
<script>
Prism.init({ release: '2026.08.27' });
</script>Copy node_modules/@misakstvanu/prism-browser/dist/prism.iife.js into a path your application
serves. The IIFE inlines web-vitals (there is no bundler behind a <script> tag to split it out),
so it is ~2.5 kB (gzipped) larger than the ESM core.
Frontend on another origin
If your pages are served from a different origin than the Laravel application (app.example.com
posting to api.example.com), point the SDK at the backend and list the page's origin on the
backend's prism.browser.origins. Nothing else changes: the route answers the preflight, allows
credentials for that origin alone, and the session cookie still tells it who is signed in.
Prism.init({ endpoint: 'https://api.example.com/_prism/browser' });Options
Everything init() accepts. Every key is optional.
| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| endpoint | string | /_prism/browser | Where reports are posted. A relative path is anchored on the page's origin at init(); use an absolute URL for a backend on another origin. Must match the backend's prism.browser.path. |
| release | string | — | The deployed version — a git SHA, a build number, a date. It rides every event and every report's session, and is what the Errors screen groups a browser error's "since" by. |
| capture | object \| 'all' | see below | Which signals the SDK captures. 'all' turns every switch on. |
| capture.errors | boolean | true | Uncaught errors, unhandled rejections and captureException(). |
| capture.console | ConsoleMethod[] \| boolean | ['warn', 'error'] | Console methods captured as log lines. true is every one of debug, log, info, warn, error; false is none. |
| capture.http | boolean | false | A log line for every fetch / XHR that failed (network failure, 4xx, 5xx). Breadcrumbs for HTTP calls do not depend on this. |
| capture.breadcrumbs | boolean | true | The SDK's own click, navigation, console and HTTP crumbs. addBreadcrumb() works regardless. |
| capture.pageViews | boolean | false | Page views — the document load and every client-side navigation — with Web Vitals on the load. Off by default because it is the one signal that grows with traffic rather than with faults. |
| tracing.propagate | 'same-origin' \| boolean \| Array<string \| RegExp> | 'same-origin' | Which outgoing calls carry a traceparent header. 'same-origin' is the page's own origin; true is every call, false none; a list matches string prefixes against the full URL or, for a same-origin call, its path ('/api'), and RegExps against the full URL. A header on a cross-origin call triggers a CORS preflight, which is why the default stops at the origin. |
| sampleRate | number | 1 | The share of page views and log lines kept, 0..1, rolled per event. Errors are never sampled. A value that is not a number is read as 1, never 0. |
| maxBreadcrumbs | number | 30 | How many breadcrumbs ride an error. 0 disables the trail. |
| scrub | string[] | see below | Context keys to redact, merged with the defaults, matched case-insensitively at any depth. |
| beforeSend | (event) => event \| null \| false | — | Inspect or rewrite every event before it is queued. Return null or false to drop it. A beforeSend that throws drops the event. |
| user | { id: string \| number } | — | The signed-in user, as a hint. See setUser(). |
| debug | boolean | false | Report the SDK's own failures through console.debug. Never on in production. |
The default capture is { errors: true, console: ['warn', 'error'], http: false, breadcrumbs:
true, pageViews: false }. A partial object is merged over it, so capture: { pageViews: true }
turns views on and leaves the rest as they are.
The default scrub list is the one the Composer client ships as prism.scrub's default, so a
page and its backend agree about what a secret looks like: password, passwd, secret, token,
api_key, apikey, authorization, cookie, credit_card, card_number, cvv, ssn. The
backend applies its own list again to everything that arrives, so a key you only add server-side is
still redacted before it reaches the workspace.
init() is idempotent: a second call updates the options and installs no second listener, so a
framework that boots twice in development costs nothing. The set of console methods wrapped is fixed
at the first call; a later init() that narrows the list stops the capture without re-wrapping.
API
The default export and the named exports are the same nine functions. All of them are safe to call
anywhere — before init(), after close(), on the server — and none of them throws.
import Prism, { init, log, captureException, addBreadcrumb, setContext, setUser, traceId, flush, close } from '@misakstvanu/prism-browser';| Call | What it does |
| --- | --- |
| init(options?) | Start the SDK. Outside a browser (no window) it returns at once and installs nothing. |
| log(message, context?, level = 'info') | Queue a log line for the workspace's Logs screen, on channel browser. level is debug | info | warning | error (warn is accepted as warning). |
| captureException(error, context?) | Report an error the page caught itself. It is marked handled and meets the same one-second dedupe as an uncaught one, so reporting an error you then also console.error costs one event, not two. Anything can be thrown: a non-Error is reported as class Error with its string form (an object JSON-encoded, up to 1 KB) as the message. |
| addBreadcrumb({ category?, message?, data?, timestamp? }) | Add a step to the trail the next error carries. category defaults to custom, the timestamp to now; data is scrubbed on the way in. Works whatever capture.breadcrumbs says — that switch governs the SDK's own listeners. |
| setContext(key, value) | Attach a value to every later event's context (undefined removes the key). Scrubbed by key like everything else. |
| setUser({ id } \| null) | Name the signed-in user. It travels as a hint on the report; the backend reads the identity off its own session and believes the hint only when it resolved nobody and prism.browser.trust_client_user is on. |
| traceId() | The page's current trace id — 32 lowercase hex, re-minted on every client-side navigation to a new path — or '' before init(). Put it in a support ticket, or on a request you make outside fetch / XHR. |
| flush() | Send everything queued now, without waiting for the batch timer. Resolves once every pending batch has settled — sent, refused, or dropped after its one retry. |
| close() | Flush, remove every listener and wrapper, and forget the client. init() starts over afterwards. |
Framework adapters
Both adapters reach the core through the package's own entry, so a page that imports both gets one
SDK, one queue and one trace id. Each does two things the core cannot see from window: report a
component error, which a framework catches before it ever reaches window.onerror, with the
component's name; and name the page after the route, so route on every event is orders.show
rather than /orders/42, and a client-side navigation counts as one page view rather than two.
Vue 3
import { createApp } from 'vue';
import Prism from '@misakstvanu/prism-browser';
import { PrismVue } from '@misakstvanu/prism-browser/vue';
Prism.init({ release: __APP_VERSION__ });
createApp(App)
.use(router)
.use(PrismVue, { router }) // router is optional
.mount('#app');The plugin installs an app.config.errorHandler that chains to whatever handler the app had
already set (capture runs first, so a chained handler that throws costs nothing) and reports each
error with context.component (the component's name, its <script setup> file name, or
Anonymous) and context.lifecycleHook (Vue's own info string). When no handler existed, the
error is also written to console.error, because Vue stops logging the moment a handler is set.
Given a vue-router instance, every event's route becomes to.name ?? to.path, and each completed
navigation leaves a navigation breadcrumb and reports the page view. A navigation with a
NavigationFailure (aborted, cancelled, duplicate) reports nothing — the page did not move. The
router is typed structurally, so the adapter's types never require vue-router to be installed.
React
import { useLocation } from 'react-router-dom';
import Prism from '@misakstvanu/prism-browser';
import { PrismErrorBoundary, useReportNavigation } from '@misakstvanu/prism-browser/react';
Prism.init({ release: process.env.REACT_APP_VERSION });
function App() {
const { pathname } = useLocation(); // react-router; Next's usePathname() works the same way
useReportNavigation(pathname);
return (
<PrismErrorBoundary fallback={<p>Something went wrong.</p>}>
<Routes />
</PrismErrorBoundary>
);
}<PrismErrorBoundary fallback onError>reports fromcomponentDidCatchwithcontext.component(the first entry of React's own component stack) andcontext.componentStack(React's text, cut at 2 KB), then rendersfallback— a node, or a function of the error — and callsonError(error, info). A thrown non-Errorreaches both as anError. There is no boundary-less capture of render errors: React unwinds to the nearest boundary and, when there is none, unmounts the whole tree, so put one near the root.withPrismErrorBoundary(Component, boundaryProps?)is the same thing as a HOC.useReportNavigation(pathname, routeName?)takes the pathname from whatever router the app uses — the adapter imports none — and on every change setsroute(torouteName, or the pathname), leaves anavigationbreadcrumb and reports the page view. The first run names the load view and leaves no crumb. It is Strict-Mode safe: every step is idempotent for a same-pathname re-run.
react is a peer dependency and is never bundled (two Reacts on one page is the "invalid hook call"
error); the adapter is plain createElement, so it needs no JSX runtime configuration.
What is sent
Every post is one JSON report:
{
"v": 1,
"sdk": { "name": "@misakstvanu/prism-browser", "version": "1.0.0" },
"sent_at": "2026-08-27T10:15:30.123Z",
"session": { "id": "…uuid…", "release": "2026.08.27" },
"user": { "id": 42 },
"events": [
{ "type": "exception", "timestamp": "…", "trace_id": "…32 hex…", "payload": { … } }
]
}session.release and user are present only when set. session.id is a UUID kept in
sessionStorage under prism:session for the life of the tab (in memory when storage is
unavailable), which is what makes "sessions" answerable beside "views" for a visitor who never
signs in. Each event's trace_id is the page trace current when it was captured.
The payload keys are the workspace's column names, verbatim. A key with no column is dropped silently on the far side, so the lists below are exact.
exception — an uncaught error, an unhandled rejection, captureException(), a
console.error(err) whose first argument is an Error, or a framework component error:
runtime "browser"
class the error's name — or its subclass's, when that is 3+ characters
message the error's message (a non-Error's string form, up to 1 KB)
file, line the top frame's — the frame the workspace fingerprints on
frames [{ file, line, column, function, vendor }], top frame first, up to 50
route the route name an adapter set, else location.pathname
handled 1 for captureException() and console.error, 0 for the two window listeners
breadcrumbs [{ timestamp, category, message, data? }], oldest first, up to maxBreadcrumbs
context see belowframes come from one parser that reads Chrome's, Firefox's and Safari's error.stack into the
same shape — vendor is a frame from another origin or a /node_modules/ path — so a fault groups
the same whichever browser threw it. A cross-origin script's Script error. carries no stack; the
browser's own file / line become the one frame so it still groups by location.
log — Prism.log(), a captured console call, or a failed HTTP call:
runtime "browser"
level debug | info | warning | error
message the line (console arguments formatted with %s %d %o … substitution, cut at 4 KB)
channel browser (Prism.log) | console | http
context see below — a console line adds `console` (the remaining arguments), an http line adds { method, url, status, duration_ms }page_view — the document load, then every client-side navigation to a new path or query
(a hash-only change is the same page). One row per view, sent when the view ends:
url location.href
path location.pathname
route the route name an adapter set, else ""
referrer document.referrer (the previous page's href for a navigation)
kind load | navigation
duration_ms loadEventEnd − startTime for a load; time on page for a navigation
status the document's response status where the browser reports one (Chromium), else 0
user_agent navigator.userAgent
viewport_w, viewport_h
release the configured release, else ""
session_id
ttfb_ms, fcp_ms, lcp_ms, cls, inp_ms Web Vitals, on kind = load only — cls is a unitless score, the rest millisecondsThe vitals come from Google's web-vitals, loaded
lazily the moment capture.pageViews is on and never otherwise, with the values final at the time
the row goes out. A metric that never landed is left off so the column keeps its default; a
navigation row carries none, because only a real document load has an LCP.
context on an exception or log is, from the bottom up: the page facts — url, referrer,
user_agent, viewport (1440x900), session_id, release, language, online — then
everything setContext() set, then the per-call context, with the scrub list applied over the
whole thing.
What the backend adds and overrides
The report is an unauthenticated write from an origin nobody controls, so four things are the
backend's answer rather than the page's. user_id is read off the backend's own session
(prism.browser.guard); the user hint in the report is used only when that resolved nobody and
the backend is configured to trust it. context.ip is the client address the server saw — a page
cannot know its own public address. context.user_agent (and a page view's user_agent column)
is the request header, written over the SDK's copy. And every timestamp is corrected against
sent_at: past a five-second tolerance the whole report shifts by the offset between the page's
clock and the server's, so a device whose clock is a year out still lands in today's partition. The
application, environment and replica the events belong to are the backend's too — they ride the
batch envelope, so the page never names them.
The backend also runs its prism.scrub list over everything, rewrites query strings in every URL
pair by pair (?token=abc&step=2 → ?token=%5BREDACTED%5D&step=2), and redacts name = value
pairs in free text. See the Composer client's README, Browser telemetry.
Sampling and quota
Browser events count against the workspace's monthly event quota like every other signal — a page
view per visit and a log line per console.warn add up on a busy site in a way errors do not, which
is why capture.pageViews and capture.http are off by default and capture.console starts at
warn. sampleRate is the first knob: it keeps that share of page views and log lines, rolled per
event, and never touches an exception. The workspace's own per-application rules (Console →
Settings → Sampling) are the second, and the two multiply. Exceptions are pinned to 100% on both
sides, and a workspace that is over quota still keeps them.
Breadcrumbs
The trail attached to every exception, newest last, capped at maxBreadcrumbs. Each crumb is
{ timestamp, category, message, data? }:
| category | message | data |
| --- | --- | --- |
| click | button#save, a.nav-link, div — tag plus id or first class | { text }, the element's text up to 64 characters — never for input, textarea, select or option, whose text is their value |
| navigation | /orders → /orders/42 (path, query and hash for a same-origin address) | { from, to } as full hrefs; an adapter adds route |
| console | the formatted line, cut at 200 characters | { level } |
| http | GET /api/orders → 503 (path for a same-origin call, origin + path otherwise, never the query) | { method, url, status, duration_ms } |
| custom (or anything you name) | yours | yours, scrubbed on the way in |
A console crumb is written after the log line or exception it describes, so a console.error(err)
does not carry itself as its own last step. An HTTP call the page aborted leaves no crumb (it neither
succeeded nor failed), and neither does the SDK's own post.
Trace propagation
init() mints a 32-hex trace id for the page and re-mints it on every client-side navigation to a
new path or query. Every event carries the id current when it was captured, and every same-origin
fetch / XHR call (see tracing.propagate) gets a W3C traceparent header —
00-{traceId}-{spanId}-01, a fresh span id per call — unless the caller already set one. The
Laravel backend continues that trace rather than starting its own (the Composer client leaves the
tracecontext propagator at its default and applies no allow-list to a browser's id), which is
what puts a JavaScript error beside the API call it made, with that call's queries and log lines, on
one Traces screen, and what lets the error detail link to the backend request of the page it broke
on.
The wrappers never mutate the caller's Headers, array or object (a copy is extended), hand back the
original's own promise, and never read a body. A URL the browser cannot resolve is passed through
untouched so the browser reports its own error.
Delivery
- Events queue in memory (at most 200; the oldest are dropped past that) and go out 5 seconds after the first one, or at once at 20. A batch is at most 50 events / 200 KB; an oversize event goes alone rather than being dropped.
- The transport is a
keepalivefetchwithcredentials: 'same-origin'— the session cookie is how the backend knows who is signed in — and no header butContent-Type. A network failure is retried once, 2 seconds later; a4xx/5xxis dropped without retry (the backend answers413for an oversize post,429when throttled, and never a body). Whilenavigator.onLine === falsenothing is sent;onlineflushes. - Leaving the page uses
navigator.sendBeacon— onpagehideand onvisibilitychange → hidden, which is when the last report of a session (and every page view's row) goes out, and the only transport a browser lets finish once the page is gone. A beacon sends the body as a string, so it arrives astext/plainand cannot set a header; the backend's route decodes the body by hand rather than by content type and runs without CSRF verification for exactly this reason. WheresendBeaconis missing or refuses, the keepalive fetch is the fallback. - Errors are deduplicated within one second — same class, message and top frame, whichever door
they came through — so a framework handler that reports and then
console.errors costs one event, and an error thrown in a loop reports once a second rather than once per iteration.
Content Security Policy
connect-src 'self' is all the SDK needs on a page whose backend is its own origin; for a
frontend on another origin, add that backend's origin to connect-src. Nothing else is touched:
the SDK injects no inline script, no <script> element, no eval and no worker, so script-src
stays whatever your bundle already requires. sendBeacon is governed by connect-src like fetch.
What is not captured
- Errors thrown before
init()runs. There is no pre-init loader snippet; callinit()as early as your bundle executes. A framework's own boot errors before that point are the framework's to log. - Resource load errors. A failed
<img>,<script>or stylesheet fireserroron the element and does not bubble towindow; the SDK's listener is deliberately non-capturing so those do not become error groups. CSP violation reports are not collected either. - Request and response bodies. The HTTP wrappers observe how a call ended — method, URL,
status, duration — and never read a body in either direction (
bodyUsedstaysfalse). - Input values. A click breadcrumb records an element's text for a button or a link, never for
an
input,textarea,selectoroption, whose text is what was typed or chosen; nothing reads a form field. - Anything the scrub list names, at any depth in a context, a breadcrumb's
dataor a URL's query string — replaced by[REDACTED]in the page, before it is queued. - Session replay, per-visitor timelines, browser spans on the trace waterfall. The trail on an
error is the breadcrumbs; a page's
fetchtimings are breadcrumbs and HTTP log lines, notspansrows — the page trace links to the backend's waterfall, it does not extend it.
Source maps and minified frames
Source maps are not consumed. Frames render in the console exactly as the browser reported them
— the bundled file, the bundled line and column, and the minified function label — and a frame
whose label is two characters or shorter is marked minified, once, above the trace. What is
engineered instead is that grouping stays stable across deploys without them: the backend
fingerprints a browser error on its class, the top application frame's file with the content hash
stripped (app-Bx3kq9.js and app-Q7mLp2.js are one file), its function label only when it is
three characters or longer (a minified t changes every build), and the message with URLs, ids and
numbers replaced by placeholders — so the same fault is one group before and after a release, and
…/orders/123 and …/orders/456 are one group too. Ship release and the group's detail says
which deploy it started in.
Development
The package is a standalone Vite / TypeScript project inside the Prism repository, with its own
yarn.lock and its own node_modules. It is deliberately not a workspace of the repository
root: the Prism console dogfoods the published package from the npm registry, the way a
customer's install resolves it, so a change here reaches the console only through npm publish
and a version bump in the root package.json. The scripts run from this directory:
yarn install # in this directory — the root install does not cover the package
yarn build # ESM (dist/index.js, vue.js, react.js), the IIFE, then the .d.ts
yarn test # vitest, happy-dom
yarn lint:check
yarn types:check
yarn check:size # the budget: index.js ≤ 10 kB gzipped, each adapter ≤ 2 kB