Observability
Grafana Faro setup for BrowserRouter, createBrowserRouter, and no router
How to set up Grafana Faro route tracking for all three cases — legacy BrowserRouter with FaroRoutes, data router createBrowserRouter with withFaroRouterInstrumentation, and apps with no router at all.
Sentry is a great error tracker. But when you need Web Vitals, session replays, route transition timing, and fetch performance all in one place — Grafana Faro is worth the switch.
Route tracking is where most Faro setups break, because the correct API depends entirely on which router you use. This guide covers all three cases with real code:
- Data router (
createBrowserRouter) —createReactRouterV6DataOptions+withFaroRouterInstrumentation - Legacy browser router (
BrowserRouter) —createReactRouterV6Options+<FaroRoutes> - No router (vanilla JS, Astro, form apps) —
@grafana/faro-web-sdkonly
If you're on BrowserRouter and following the official data-router docs, that mismatch is why your route events never show up. Jump to Case 2.
What Faro gives you that Sentry doesn't
| Signal | Sentry | Faro |
|---|---|---|
| JS errors | ✓ | ✓ |
| Web Vitals (LCP, CLS, INP) | partial | ✓ |
| Fetch / XHR timing | ✗ | ✓ |
| Route change tracking | ✗ | ✓ |
| Session context | ✓ | ✓ |
| User session replay | ✓ | separate product |
They can coexist. Different signals, different dashboards. You don't have to rip Sentry out.
Install
# React app
pnpm add @grafana/faro-react
# Vanilla JS / no React
pnpm add @grafana/faro-web-sdk
The singleton module
Always create one centralised init file. Never call initializeFaro in multiple places without a guard — the SDK will warn and produce duplicate telemetry.
// src/faro.ts
import { initializeFaro, getWebInstrumentations } from '@grafana/faro-react';
import type { Faro } from '@grafana/faro-react';
let instance: Faro | undefined;
export function initialiseFaro(): void {
const url = process.env.FARO_COLLECTOR_URL;
// disable in local dev and if URL is missing
if (!url || instance) return;
instance = initializeFaro({
url,
app: {
name: 'my-app',
version: '1.0.0',
environment: process.env.NODE_ENV,
},
instrumentations: [
...getWebInstrumentations(), // errors, console, Web Vitals, fetch
],
});
}
export const getFaro = () => instance;
Call it once, before React mounts:
// entry.tsx
initialiseFaro();
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
Order matters. Faro before React ensures startup errors are captured.
Grafana Faro router setup: the part the docs skip
Route tracking is where most migrations break. There are three cases.
Case 1 — Grafana Faro with createBrowserRouter (data router)
This is the modern React Router v6 setup. Faro has first-class support.
import {
createReactRouterV6DataOptions,
ReactIntegration,
} from '@grafana/faro-react';
import { matchRoutes } from 'react-router-dom';
import { withFaroRouterInstrumentation } from '@grafana/faro-react';
// in initializeFaro instrumentations:
new ReactIntegration({
router: createReactRouterV6DataOptions({ matchRoutes }),
})
// wrap the router:
const router = withFaroRouterInstrumentation(
createBrowserRouter(routes)
);
withFaroRouterInstrumentation subscribes to router.subscribe() — an internal API only available on data routers. This is why it won't work on anything else.
Case 2 — Grafana Faro with BrowserRouter (legacy browser router)
Many apps — especially older ones or micro-frontends — use BrowserRouter. The data router APIs don't exist here.
createReactRouterV6DataOptions will silently fail or throw. Don't use it.
Use createReactRouterV6Options instead, and swap <Routes> for <FaroRoutes> in JSX:
import {
createReactRouterV6Options,
ReactIntegration,
FaroRoutes,
} from '@grafana/faro-react';
import {
createRoutesFromChildren,
matchRoutes,
Routes,
useLocation,
useNavigationType,
} from 'react-router-dom';
// in initializeFaro instrumentations:
new ReactIntegration({
router: createReactRouterV6Options({
createRoutesFromChildren,
matchRoutes,
Routes,
useLocation,
useNavigationType,
}),
})
// in your app JSX — replace <Routes> with <FaroRoutes>
<BrowserRouter>
<FaroRoutes>{routeList}</FaroRoutes>
</BrowserRouter>
FaroRoutes is a thin wrapper — it renders your actual <Routes> internally and fires EVENT_ROUTE_CHANGE on every PUSH or POP navigation. Behaviour is identical to <Routes> from the user's perspective.
All five dependencies are required:
| Dependency | Purpose |
|---|---|
createRoutesFromChildren | converts JSX <Route> tree → route objects |
matchRoutes | resolves current URL to a route pattern |
Routes | rendered internally by FaroRoutes |
useLocation | detects URL changes |
useNavigationType | filters REPLACE navigations (redirects) to avoid noise |
Case 3 — Grafana Faro with no router (vanilla JS, Astro, form apps)
No React Router at all. No route tracking is needed — and that's fine.
Use @grafana/faro-web-sdk directly. Skip ReactIntegration entirely.
import { initializeFaro, getWebInstrumentations } from '@grafana/faro-web-sdk';
initializeFaro({
url: COLLECTOR_URL,
app: { name: 'my-vanilla-app', version: '1.0.0', environment: 'staging' },
instrumentations: [...getWebInstrumentations()],
});
getWebInstrumentations() still gives you JS errors, console logs, Web Vitals, and fetch tracking — everything except route transitions, which don't exist in this context.
One Astro-specific gotcha: process.env doesn't exist in browser bundles. Use import.meta.env instead.
// Astro / Vite
const url = import.meta.env.PUBLIC_FARO_COLLECTOR_URL;
// Rsbuild / Webpack
const url = process.env.PUBLIC_FARO_COLLECTOR_URL;
Environment strategy
Faro can generate significant ingest volume. Don't run it everywhere.
const isEnabled =
process.env.NODE_ENV !== 'development' &&
!!process.env.FARO_COLLECTOR_URL;
if (!isEnabled || instance) return;
Keep FARO_COLLECTOR_URL empty in local dev env files. Faro skips init silently when the URL is missing — no errors thrown.
Verifying it works
Open DevTools → Network → filter by collect. You should see periodic POST requests.
Click one. The payload should contain:
{
"meta": {
"app": { "name": "my-app", "version": "1.0.0", "environment": "staging" }
},
"events": [...],
"measurements": [...],
"logs": [...]
}
Check meta.app.name. If you see a different app's name — another Faro instance initialised first (common in micro-frontend setups where the host shell runs its own Faro). Your singleton guard is working correctly; the host just got there first.
Custom telemetry
Once initialised, the API is straightforward:
import { getFaro } from './faro';
// log
getFaro()?.api.pushLog(['form submitted'], { level: 'info' });
// error
getFaro()?.api.pushError(new Error('payment failed'));
// product event
getFaro()?.api.pushEvent('batch_enrolled', { batchId: '123' });
// user context
getFaro()?.api.setUser({ id: 'user_456' });
Use getFaro()? — the optional chain handles the case where Faro was disabled (local dev or missing URL).
Summary
The migration is straightforward once you know which router API to use.
- Data router (
createBrowserRouter) →createReactRouterV6DataOptions+withFaroRouterInstrumentation - Legacy browser router (
BrowserRouter) →createReactRouterV6Options+<FaroRoutes> - No router (vanilla JS, Astro) →
@grafana/faro-web-sdk+getWebInstrumentations()only
Start with a singleton, disable locally, verify with the network tab. The rest is just wiring.