Operating It
Error tracking that reports once, the numbers worth reading in week one, and how they decide what the next release changes.
onErrorCaptured((err, instance, info) => boolean | void)Shipping is the start of the work. Three things tell you whether the application is doing its job: errors it reports, numbers you read, and a switch you can turn off.
Catching everything once
Errors reach you from four places. app.config.errorHandler receives anything thrown inside a component, a watcher or a lifecycle hook. router.onError receives failures in a guard or in a lazy route's import, which is what a failed chunk load after a deploy looks like. An unhandledrejection listener catches promises nobody awaited. onErrorCaptured inside a component catches errors from its descendants, which lets one failing section degrade instead of blanking the page.
Deduplicate before sending. A render error in a list can fire on every item, and an unthrottled reporter will send thousands of identical requests from one browser.
The first week
Four numbers are enough: how many sessions reached the list, how many saves failed, the rate of 5xx responses from the API, and how long the first contentful paint takes on a real connection. Read them together. A spike in failed saves with no error reports usually means validation the form should have caught first.
What the next release changes
A feature flag is a value read at runtime that turns a feature on. Shipping the next feature behind one means the rollback is a configuration change, not a redeploy.
Example
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'
import { reportError } from '@/lib/report'
const failed = ref(false)
onErrorCaptured((error, _instance, info) => {
failed.value = true
reportError(error, { hook: info })
return false
})
function retry() {
failed.value = false
}
</script>
<template>
<div v-if="failed" role="alert">
<p>This section could not load.</p>
<button type="button" @click="retry">Try again</button>
</div>
<slot v-else />
</template>
<style scoped>
div {
padding: 1rem;
border: 1px solid currentColor;
}
</style>When to use it
- Letting one broken panel fail without blanking the whole screen.
- Telling a failed chunk load after a deploy apart from a bug in a component.
- Collapsing a render-loop error storm into a single report per browser.
- Keeping a newly shipped feature behind a flag so one list decides who sees it.
More examples
Wiring every error source at startup
One file registers all four handlers, in the order they are needed: the rejection listener before the app exists, then the component handler, then the router's. Each reports with a different hook label, so the tracker tells a guard failure apart from a render failure without reading the stack.
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { initReporting, reportError } from './lib/report'
initReporting()
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.config.errorHandler = (error, _instance, info) => {
reportError(error, { hook: info })
}
router.onError((error) => {
reportError(error, { hook: 'router' })
})
app.mount('#app')A reporter that sends each error once
The Set of seen keys collapses a storm of identical errors into one request, and the cap stops a long session from reporting forever. Point the endpoint at your own API or swap the fetch for a tracking SDK; the deduplication belongs here either way, because it is what keeps a render loop from flooding the endpoint.
// src/lib/report.ts
interface Context {
hook: string
}
const seen = new Set<string>()
const MAX_REPORTS = 25
export function initReporting(): void {
window.addEventListener('unhandledrejection', (event) => {
reportError(event.reason, { hook: 'unhandledrejection' })
})
}
export function reportError(error: unknown, context: Context): void {
const message = error instanceof Error ? error.message : String(error)
const key = `${context.hook}:${message}`
if (seen.has(key) || seen.size >= MAX_REPORTS) return
seen.add(key)
void fetch('/api/client-errors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, hook: context.hook }),
})
}
// reportError(new Error('boom'), { hook: 'render' }) // Output: one request sent
// reportError(new Error('boom'), { hook: 'render' }) // Output: nothing sent
// seen.size // Output: 1The flag that decides the next release
Flags come from one environment variable as a comma-separated list, parsed once. A component asks isEnabled before rendering the new feature. Vite statically replaces import.meta.env.VITE_FLAGS at build time, so changing the list means a rebuild and a redeploy; to flip a flag without rebuilding, keep this module's shape but fetch the list from the server at startup. Reach for this when a feature is ready to deploy but not ready to be seen by everyone.
// src/lib/flags.ts
const raw = import.meta.env.VITE_FLAGS ?? ''
const enabled = new Set(
raw
.split(',')
.map((flag) => flag.trim())
.filter(Boolean),
)
export function isEnabled(flag: string): boolean {
return enabled.has(flag)
}
// With VITE_FLAGS="bulk-edit, csv-export"
// isEnabled('bulk-edit') // Output: true
// isEnabled('csv-export') // Output: true
// isEnabled('dark-mode') // Output: false
// enabled.size // Output: 2
Discussion