monitoring
When something breaks in production, you want to know before a user writes in, and you want enough context to fix it: who it happened to, which page they were on and what they did beforehand. @kitr/monitoring catches render errors, uncaught errors and failed server requests, adds the signed-in user, tags and breadcrumbs, and sends each report to Sentry or any other service through one small API.
The examples on this page follow one scenario: Acme's app at app.acme.com, where Ada imports projects from a CSV file, Grace manages her team's invoices, and Linus is on call when a report comes in.
@kitr/monitoring runs in the browser and on the server. The provider and its error boundary need React; a server-only project uses the same API without the provider. It is for failures someone should fix. For lines a developer reads while debugging, use the logger; for counting what users do, use analytics.
Install
kitr add monitoringFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
What kitr add does depends on whether the project has React. In a project with React (every kit with a web platform):
- Installs
@kitr/monitoring. - Adds
'@kitr/monitoring'topackagesand() => import('@kitr/monitoring/provider')toprovidersinkitrrc.ts. - Declares
VITE_SENTRY_DSN: e.string({ optional: true })underpublicinapp/env.ts, and addsVITE_SENTRY_DSN=to.env.example(and.envwhen it exists). - Writes
config/monitoring.tsfor the browser (shown in Configure) and regeneratesconfig/index.ts. - Adds a
## kitr: monitoringsection toAGENTS.md: what is reported automatically, how to report caught errors,setUseron sign-in and sign-out, and the server middleware.
In a server-only project (the server kit, or any project with the server package and no React):
- Installs
@kitr/monitoring. - Adds
'@kitr/monitoring'topackagesinkitrrc.ts. It adds no provider, because the provider imports React. See Report server errors for the two lines that replace it. - Adds
SENTRY_DSN=under a# Monitoringcomment to.env.example(and.envwhen it exists). - Writes
config/monitoring.tsfor the server and regeneratesconfig/index.ts. - Adds a
## kitr: monitoringsection toAGENTS.mdabout the server middleware andcaptureException.
kitr add monitoring asks no questions. To edit package.json without running the package manager, pass the global flag:
kitr add monitoring --no-installFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
The saas kit includes monitoring.
kitr remove monitoring reverses the provider, packages entry, dependency and AGENTS.md steps, and deletes config/monitoring.ts. The env variable and its app/env.ts entry stay.
Configure
In a project with React, kitr add monitoring writes this file:
// config/monitoring.ts
import { defineConfig } from '@kitr/monitoring'
import { env } from '#app/env'
/**
* Reporters: development prints to the console, production sends nowhere until one is added.
* Pass the SDK module so the right flavour (react, browser, node) is bundled for the environment.
*/
export default defineConfig({
reporters: [
// import * as Sentry from '@sentry/react' (pnpm add @sentry/react)
// ...(env.VITE_SENTRY_DSN ? [sentry(Sentry, { dsn: env.VITE_SENTRY_DSN })] : []),
],
// shouldReport: (error) => !(error instanceof KitrError && error.status < 500),
tags: { app: env.VITE_APP_NAME },
})
In a server-only project it writes this one, with service set to the name in your package.json:
// config/monitoring.ts
import { defineConfig } from '@kitr/monitoring'
/** Reporters: development prints to the console, production sends nowhere until one is added. */
export default defineConfig({
reporters: [
// import * as Sentry from '@sentry/node' (pnpm add @sentry/node)
// ...(process.env.SENTRY_DSN ? [sentry(Sentry, { dsn: process.env.SENTRY_DSN })] : []),
],
tags: { service: 'acme-api' },
})
| Option | Default | Description |
|---|---|---|
reporters |
[] |
Where reports go. With none, the provider uses the console reporter in development and nothing in production. |
shouldReport(error, context) |
defaultShouldReport |
Return false to drop an error. The default drops kitr errors with a status below 500 and AbortErrors. Applies to captureException only. |
globalHandlers |
true |
In the browser, report uncaught errors and unhandled promise rejections. |
tags |
none | Tags added to every exception and message. The provider adds environment (the app's mode: development, production or test) under yours. |
debug |
in development, when reporters is set |
Also print each exception and message to the console with a [monitoring] prefix. Off when the provider falls back to the console reporter, which already prints them. |
| Variable | Used for |
|---|---|
VITE_SENTRY_DSN |
The Sentry DSN in a project with React. Public, because the browser sends reports. |
SENTRY_DSN |
The Sentry DSN in a server-only project. |
Neither variable does anything until you uncomment the reporter line. Until then, development prints errors to the console and devtools, and production sends them nowhere.
Report your first error
Errors you catch and handle are not reported unless you say so. In a component, read the service with useService('monitoring') and call captureException in the catch:
// app/components/projects/import-projects-button.tsx
import { useService } from '@kitr/react'
export function ImportProjectsButton({ file }: { file: File }) {
const monitoring = useService('monitoring')
async function importProjects() {
try {
const res = await fetch('/api/projects/import', { method: 'POST', body: file })
if (!res.ok) throw new Error(`Import failed with ${res.status}`)
} catch (error) {
monitoring.captureException(error, { extra: { fileName: file.name, size: file.size } })
alert('That file could not be imported.')
}
}
return (
<button type="button" onClick={importProjects}>
Import projects
</button>
)
}
Make the import fail in development and the console reporter prints the report:
[monitoring] error Error: Import failed with 500 { fileName: 'projects.csv', size: 18234 }
With devtools open, the report also appears in the Errors panel with its stack and extra data. Add a reporter, as in the next section, and the same call sends it to Sentry in production.
What is reported for you
You do not need try/catch for these:
| What | Where | How | source |
|---|---|---|---|
| An error thrown while a component renders | Browser | The provider wraps the app in MonitoringErrorBoundary, which reports, then rethrows so the router's error screen shows. |
error-boundary |
| An uncaught error | Browser | A window error listener, unless globalHandlers: false. |
window |
| A rejected promise nobody handled | Browser | A window unhandledrejection listener, unless globalHandlers: false. |
window |
| An error thrown by a server route | Server | The reportErrors middleware, once you add it. |
server |
Every report goes through shouldReport first. Expected failures are not incidents: a KitrError with a status below 500 (not found, validation, unauthorized) and an aborted fetch are dropped by default.
React retries a failed render, and StrictMode renders twice, so one render error can throw several times. The boundary reports it once.
Reporters
A reporter sends reports to one destination. List any number in reporters; each report goes to all of them. A reporter that throws is ignored, so a broken one never breaks your app.
Sentry in the browser
pnpm add @sentry/react
// config/monitoring.ts
import * as Sentry from '@sentry/react'
import { defineConfig, sentry } from '@kitr/monitoring'
import { env } from '#app/env'
export default defineConfig({
reporters: [
...(env.VITE_SENTRY_DSN
? [sentry(Sentry, { dsn: env.VITE_SENTRY_DSN, release: '[email protected]', tracesSampleRate: 0 })]
: []),
],
tags: { app: env.VITE_APP_NAME },
})
In .env:
VITE_SENTRY_DSN=https://<key>@o123.ingest.sentry.io/456
You pass the Sentry module yourself, so the bundle contains @sentry/react in the browser and nothing kitr chose for you. Every option you pass to sentry() (dsn, environment, release, tracesSampleRate and any other) goes to Sentry.init, which the reporter calls when the monitoring service is created.
Sentry on the server
pnpm add @sentry/node
// config/monitoring.ts
import * as Sentry from '@sentry/node'
import { defineConfig, sentry } from '@kitr/monitoring'
export default defineConfig({
reporters: [...(process.env.SENTRY_DSN ? [sentry(Sentry, { dsn: process.env.SENTRY_DSN })] : [])],
tags: { service: 'acme-api' },
})
In .env:
SENTRY_DSN=https://<key>@o123.ingest.sentry.io/789
See Sentry for Node.js. In a project with both a web and a server platform, config/monitoring.ts is shared, so pick the SDK per side or keep one config per platform.
The reporter maps each call to the SDK: captureException and captureMessage with your tags, extra and level (messages default to info), setUser, addBreadcrumb, setTag and flush.
Console
// config/monitoring.ts
import { consoleReporter, defineConfig } from '@kitr/monitoring'
export default defineConfig({ reporters: [consoleReporter()], debug: false })
Prints exceptions with console.error('[monitoring]', source, error, extra) and messages with console.info. Breadcrumbs and users are ignored. No env variables. The provider adds it in development when reporters is empty, and leaves debug off then. When you list it yourself in development, set debug: false next to it, or each report prints twice.
Devtools
devtoolsReporter(hub) sends exceptions and messages to the devtools errors channel and breadcrumbs to breadcrumbs. The provider adds it for you whenever devtools is active; you never list it yourself.
Memory
memoryReporter() keeps reports in arrays for tests. See Testing.
Report a caught error
captureException(error, context?) reports an error you handled. The context is optional:
| Field | Description |
|---|---|
extra |
Any data that helps: ids, sizes, the input that failed. |
tags |
Short string values you filter by in Sentry, such as { feature: 'billing' }. Merged over the config tags. |
level |
'fatal', 'error', 'warning', 'info' or 'debug'. |
source |
Where it came from. The built-in sources are error-boundary, window and server. |
// app/lib/invoices/download-invoice.ts
import type { KitrApp } from 'kitr/app'
export async function downloadInvoice(app: KitrApp, invoiceNumber: string) {
const monitoring = app.container.use('monitoring')
try {
const res = await fetch(`/api/invoices/${invoiceNumber}.pdf`)
if (!res.ok) throw new Error(`PDF download failed with ${res.status}`)
return await res.blob()
} catch (error) {
monitoring.captureException(error, {
tags: { feature: 'billing' },
extra: { invoiceNumber },
level: 'warning',
})
return null
}
}
Outside React, the service is app.container.use('monitoring'). Don't report an error and then rethrow it to a place that reports it again, such as the error boundary or the server middleware: you get two reports for one failure. Either handle and report, or let it propagate.
Send a message
captureMessage(message, context?) reports something worth knowing that is not an exception:
// app/lib/projects/import-limit.ts
import type { KitrApp } from 'kitr/app'
export function warnImportLimit(app: KitrApp, teamId: string, rows: number) {
app.container.use('monitoring').captureMessage('project import over the row limit', {
level: 'warning',
tags: { feature: 'import' },
extra: { teamId, rows, limit: 5_000 },
})
}
Messages skip shouldReport; every call is sent.
Identify the user
setUser(user) attaches the signed-in user to every report that follows, so Linus can see that Ada hit the error and contact her. Call it after sign-in and pass null on sign-out:
// app/lib/auth/session.ts
import type { KitrApp } from 'kitr/app'
export function onSignedIn(app: KitrApp, user: { id: string; email: string; teamId: string }) {
app.container.use('monitoring').setUser({ id: user.id, email: user.email, teamId: user.teamId })
}
export function onSignedOut(app: KitrApp) {
app.container.use('monitoring').setUser(null)
}
The user needs an id; email, username and any other field are optional. Only send what your privacy policy allows. An id alone is often enough to look the person up.
Leave breadcrumbs
A breadcrumb is a step on the way to a possible error. Reporters that support them, such as Sentry, attach the most recent ones to the next report:
// app/lib/projects/import-projects.ts
import type { KitrApp } from 'kitr/app'
export async function importProjects(app: KitrApp, file: File) {
const monitoring = app.container.use('monitoring')
monitoring.addBreadcrumb({ category: 'import', message: 'file selected', data: { name: file.name } })
const text = await file.text()
monitoring.addBreadcrumb({ category: 'import', message: 'parsed CSV', data: { rows: text.split('\n').length } })
return text
}
| Field | Description |
|---|---|
message |
What happened. Required. |
category |
A group, such as import, navigation or api. |
level |
Same values as level above. |
data |
Extra data. |
timestamp |
Milliseconds since the epoch. Set to Date.now() when left out. |
The console and memory reporters ignore breadcrumbs; devtools lists them under Breadcrumbs.
Tag reports
Tags are short strings you filter by. Set them in three places, from widest to narrowest:
tagsinconfig/monitoring.ts, on every report from the start. The provider addsenvironmentwith the app's mode.setTag(key, value)at run time, for something that stays true for the session, such as the team plan.tagsin the context of one call, as in Report a caught error.
// app/lib/teams/team-context.ts
import type { KitrApp } from 'kitr/app'
export function onTeamSelected(app: KitrApp, team: { id: string; plan: 'free' | 'pro' }) {
const monitoring = app.container.use('monitoring')
monitoring.setTag('team', team.id)
monitoring.setTag('plan', team.plan)
}
setTag is passed to reporters that keep their own scope, such as Sentry. The config tags and per-call tags are merged into each report by @kitr/monitoring itself, with per-call tags winning.
Wrap a function
wrap(fn, context?) runs fn, reports anything it throws or rejects with, and rethrows. It works for sync and async functions:
// server/services/invoice-service.ts
import type { Monitoring } from '@kitr/monitoring'
export function totalInvoice(monitoring: Monitoring, lines: Array<{ amount: number }>) {
return monitoring.wrap(() => lines.reduce((sum, line) => sum + line.amount, 0), {
tags: { feature: 'billing' },
})
}
export async function sendInvoice(monitoring: Monitoring, invoiceNumber: string) {
return monitoring.wrap(
async () => {
const res = await fetch(`https://billing.acme.com/invoices/${invoiceNumber}/send`, { method: 'POST' })
if (!res.ok) throw new Error(`billing API ${res.status}`)
return res.status
},
{ extra: { invoiceNumber } },
)
}
Use it for work that runs outside a request or render, such as a timer or a queue consumer, where nothing else would see the error.
Ignore expected errors
shouldReport(error, context) decides whether captureException sends anything. The default is defaultShouldReport, which you can extend instead of replacing:
// config/monitoring.ts
import { defaultShouldReport, defineConfig } from '@kitr/monitoring'
import { env } from '#app/env'
export default defineConfig({
reporters: [],
shouldReport(error, context) {
// Browser extensions throw from their own scripts; they are not our bugs.
if (context?.source === 'window' && error instanceof Error && /extension:\/\//.test(error.stack ?? ''))
return false
return defaultShouldReport(error)
},
tags: { app: env.VITE_APP_NAME },
})
// scripts/should_report.ts
import { KitrError } from 'kitr/app'
import { defaultShouldReport } from '@kitr/monitoring'
defaultShouldReport(KitrError.notFound('Project')) // => false
defaultShouldReport(new KitrError('INTERNAL', 'db down')) // => true
defaultShouldReport(Object.assign(new Error('aborted'), { name: 'AbortError' })) // => false
defaultShouldReport(new Error('Import failed with 500')) // => true
Show a fallback for a render error
The provider's boundary rethrows so the router's error component renders. To keep the rest of a page working when one widget fails, put your own MonitoringErrorBoundary around it with a fallback:
// app/components/invoices/invoice-chart-section.tsx
import { MonitoringErrorBoundary } from '@kitr/monitoring/provider'
import { useService } from '@kitr/react'
import { InvoiceChart } from '#app/components/invoices/invoice-chart'
export function InvoiceChartSection() {
const monitoring = useService('monitoring')
return (
<MonitoringErrorBoundary
monitoring={monitoring}
fallback={(error, reset) => (
<div role="alert">
<p>The chart could not be drawn: {(error as Error).message}</p>
<button type="button" onClick={reset}>
Try again
</button>
</div>
)}
>
<InvoiceChart />
</MonitoringErrorBoundary>
)
}
| Prop | Description |
|---|---|
monitoring |
The service to report to. |
fallback |
A node, or (error, reset) => node. reset() clears the error and renders the children again. Left out, the boundary rethrows after reporting. |
children |
What it guards. |
The report carries source: 'error-boundary' and the React component stack in extra.componentStack.
Report server errors
On the server, reportErrors(monitoring) from @kitr/monitoring/server is middleware that reports every error a route throws, with the method, URL and request id, then rethrows so kitr's error handler still sends the response.
In a project with React, the provider binds the service on the server too. The app boots on the first request, so resolve the service per request:
// server/main.ts
import { mountRoutes } from '@kitr/api'
import { reportErrors } from '@kitr/monitoring/server'
import { createServer } from '@kitr/server'
import { config } from '../config'
import rc from '../kitrrc'
import { routes } from '#server/routes'
export const server = createServer({ providers: rc.providers, config })
mountRoutes(server, routes)
// Report thrown errors before kitr's error handler answers.
server.use((event, next) => reportErrors(server.app.container.use('monitoring'))(event, next))
In a server-only project there is no provider, so create the service from the config and bind it yourself. Then app.container.use('monitoring') works everywhere, as AGENTS.md says:
// server/main.ts
import { mountRoutes } from '@kitr/api'
import { createMonitoring } from '@kitr/monitoring'
import { reportErrors } from '@kitr/monitoring/server'
import { createServer } from '@kitr/server'
import { config } from '../config'
import rc from '../kitrrc'
import { routes } from '#server/routes'
export const server = createServer({ providers: rc.providers, config })
mountRoutes(server, routes)
const monitoring = createMonitoring(config.monitoring)
server.app.container.value('monitoring', monitoring)
server.use(reportErrors(monitoring))
A route that throws new Error('db down') while handling GET /api/projects is reported as:
// => error: Error('db down')
// => context: {
// source: 'server',
// extra: { method: 'GET', url: 'https://app.acme.com/api/projects', requestId: '9b2f6c1e-...' },
// tags: { service: 'acme-api' },
// }
The client still gets kitr's normal 500 response. A route that throws KitrError.notFound() answers 404 and is not reported. createMonitoring used directly does not add the console reporter or the environment tag; add them in the config if you want them.
Flush before the process exits
Reporters send in the background. In a script, a serverless function or a shutdown hook, wait for them with flush(timeoutMs?) before the process ends:
// scripts/rebuild_invoices.ts
import { createMonitoring } from '@kitr/monitoring'
import { config } from '../config'
const monitoring = createMonitoring(config.monitoring)
try {
throw new Error('invoice INV-2026-007 has no lines')
} catch (error) {
monitoring.captureException(error, { tags: { job: 'rebuild_invoices' } })
} finally {
await monitoring.flush(2_000)
}
flush waits for every reporter that has a flush method, up to the timeout (2 seconds by default), and never throws.
Testing
memoryReporter() keeps every report in arrays. Boot an app with the provider and a memory reporter, run the code, and assert on what was reported:
// test/download-invoice.test.ts
import { memoryReporter } from '@kitr/monitoring'
import monitoringProvider from '@kitr/monitoring/provider'
import { createTestApp } from '@kitr/testing'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { downloadInvoice } from '#app/lib/invoices/download-invoice'
afterEach(() => vi.unstubAllGlobals())
describe('downloadInvoice', () => {
it('reports a failed download with the invoice number', async () => {
const reporter = memoryReporter()
const app = await createTestApp({
providers: [monitoringProvider],
config: { monitoring: { reporters: [reporter], globalHandlers: false } },
})
vi.stubGlobal('fetch', async () => new Response('nope', { status: 503 }))
expect(await downloadInvoice(app, 'INV-2026-007')).toBeNull()
expect(reporter.exceptions).toHaveLength(1)
const [report] = reporter.exceptions
expect((report?.error as Error).message).toBe('PDF download failed with 503')
expect(report?.context).toMatchObject({
level: 'warning',
extra: { invoiceNumber: 'INV-2026-007' },
tags: { environment: 'test', feature: 'billing' },
})
})
})
| Field | Holds |
|---|---|
exceptions |
{ error, context } for each reported exception, after shouldReport and with tags merged. |
messages |
The text of each captureMessage. |
user |
The last value passed to setUser. |
Breadcrumbs and tags set with setTag are not kept. To check that an expected error is dropped, assert expect(reporter.exceptions).toHaveLength(0).
Without the provider, createMonitoring({ reporters: [reporter] }) gives you the same service with no environment tag. Use it to test the server middleware without a server:
// test/report-errors.test.ts
import { createMonitoring, memoryReporter } from '@kitr/monitoring'
import { reportErrors } from '@kitr/monitoring/server'
import { expect, it } from 'vitest'
it('reports a failed route with its request id', async () => {
const reporter = memoryReporter()
const middleware = reportErrors(createMonitoring({ reporters: [reporter] }))
const event = {
req: { method: 'GET', url: 'https://app.acme.com/api/projects' },
context: { kitr: { requestId: 'req_1' } },
}
await expect(
middleware(event, async () => {
throw new Error('db down')
}),
).rejects.toThrow('db down')
expect(reporter.exceptions[0]?.context).toEqual({
source: 'server',
extra: { method: 'GET', url: 'https://app.acme.com/api/projects', requestId: 'req_1' },
})
})
Extend
A reporter is an object with a name and a captureException method. Every other method is optional: init, captureMessage, setUser, addBreadcrumb, setTag and flush. See Reporter.
This one posts each exception to an internal endpoint, with the user and the last ten breadcrumbs:
// app/monitoring/acme-reporter.ts
import type { Breadcrumb, MonitoringUser, Reporter } from '@kitr/monitoring'
export function acmeReporter(options: { endpoint: string }): Reporter {
let user: MonitoringUser | null = null
const crumbs: Breadcrumb[] = []
const pending = new Set<Promise<unknown>>()
return {
name: 'acme',
setUser: (next) => {
user = next
},
addBreadcrumb: (crumb) => {
crumbs.push(crumb)
if (crumbs.length > 10) crumbs.shift()
},
captureException(error, context) {
const body = JSON.stringify({
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
user,
breadcrumbs: crumbs,
...context,
})
const sent = fetch(options.endpoint, { method: 'POST', body, keepalive: true })
pending.add(sent)
void sent.finally(() => pending.delete(sent))
},
async flush(timeoutMs = 2_000) {
const timeout = new Promise<false>((resolve) => setTimeout(() => resolve(false), timeoutMs))
return Promise.race([Promise.all(pending).then(() => true), timeout])
},
}
}
Register it in config/monitoring.ts next to any others:
// config/monitoring.ts
import { defineConfig } from '@kitr/monitoring'
import { acmeReporter } from '#app/monitoring/acme-reporter'
import { env } from '#app/env'
export default defineConfig({
reporters: [acmeReporter({ endpoint: 'https://errors.acme.com/ingest' })],
tags: { app: env.VITE_APP_NAME },
})
captureException must not throw or block: return at once and send in the background. The service calls each reporter inside try/catch and ignores rejected promises, so a failing endpoint loses its reports silently. init runs once, when the service is created. Breadcrumbs reach you without the merged tags; exceptions and messages arrive with them.
Devtools
With devtools installed, the provider registers an Errors panel and a devtools reporter in development.
- The panel lists captured exceptions, messages and
setUsercalls, newest first, with the time, thesourceas a badge, the stack and theextradata. The latest one is expanded. - Its tab shows a badge with the number of exceptions.
- Below the list, the last 20 breadcrumbs.
- The Clear errors action empties the list. Throw a test error throws an uncaught error, so you can check the global handler without writing code.
Next steps
- Monitoring API reference: every export with its signature.
- Sentry for JavaScript: the SDK behind the
sentry()reporter. - Logger: lines for debugging, next to reports for fixing.
- Server: request ids and kitr's error responses.
- Devtools: the Errors panel in development.
- Auth: where to call
setUserafter sign-in.