Operating It

Error tracking that reports once, the numbers worth reading in week one, and how they decide what the next release changes.

SyntaxonErrorCaptured((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

Example · vue
<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.

Example · ts
// 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.

Example · ts
// 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: 1

The 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.

Example · ts
// 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

  • Be the first to comment on this lesson.