Reporting Vue errorHandler to an Observability SDK

Vue 3 hands your global app.config.errorHandler three arguments — the error, the component instance, and a Vue-specific info string — but the default forwarding recipes for most observability SDKs quietly drop the last two. The result is a telemetry stream full of bare stack traces with no component name and no lifecycle phase. This how-to shows how to wire app.config.errorHandler into a Sentry or Datadog captureException call so every event arrives fully enriched, extending Vue 3 Error Capturing & Fallback Strategies and the runtime interception model in Core JavaScript Error Handling & Boundaries. If your errors are async and never reaching the handler at all, start with Handling Async Errors in Vue 3 Composition API Setup first.

Symptom / Trigger

Errors show up in your SDK dashboard, but they are useless for triage. Every event is grouped under the same generic fingerprint, the component that failed is anonymous, and you cannot tell a render crash from a watcher failure. In Sentry, the issue title is just the error message with no vue context tab; in Datadog RUM the error has no custom attributes. A raw event looks like this:

TypeError: Cannot read properties of undefined (reading 'total')
    at Proxy.render (CartSummary.vue:22:18)
    at renderComponentRoot (runtime-core.esm-bundler.js:891:44)
    at ReactiveEffect.componentUpdateFn (runtime-core.esm-bundler.js:5563:57)

  context: (none)
  component: (unknown)
  vue.info: (missing)

The minified frames (runtime-core.esm-bundler.js) tell you Vue caught this internally, but nothing tells you it was CartSummary failing during its render function. That missing metadata is what makes an error list impossible to prioritise. When every event carries the same three anonymous framework frames at the top of its stack, your SDK’s grouping algorithm folds unrelated failures into a single issue: a null-reference in the cart, a formatting bug in the header, and a broken watcher in the sidebar all land under one title. You lose the ability to see which component is actually degrading, how often, and for how many users — the exact questions an observability tool exists to answer.

Vue error reporting pipeline into an observability SDKA horizontal pipeline with three stages. A component error enters app.config.errorHandler, which then calls the SDK captureException method. Arrows point left to right between the boxes.Component errorerrorHandlercaptureException

Root Cause Explanation

The problem is not that Vue withholds context — it is that the common one-liner forwarding pattern throws that context away. Most quick-start snippets look like this:

// main.js — the lossy pattern
import * as Sentry from '@sentry/vue';

app.config.errorHandler = (err) => {
  // Only the error is forwarded. instance and info are ignored.
  Sentry.captureException(err);
};

By declaring the handler with a single parameter, the instance and info arguments Vue passes are silently discarded. Vue always calls the handler with the full three-argument signature (err, instance, info). The instance is the component’s public proxy, from which you can read the resolved component name; the info is a stable Vue string naming the lifecycle phase where the error was caught — values such as "render function", "setup function", "watcher callback", "native event handler", and "scheduler flush". Without threading those into the capture call, your SDK receives only what the raw Error object carries, which is the message and a stack of minified framework frames.

There is a second, subtler contributor. Sentry’s own @sentry/vue integration attaches some of this automatically when you pass the app instance to Sentry.init, but a hand-rolled errorHandler that calls captureException directly bypasses that integration and loses the enrichment. Teams frequently start with the official integration, then add a custom errorHandler later for some bespoke logging, and unknowingly override the integration’s own handler — because app.config.errorHandler is a single assignable slot, not an event bus. The last assignment wins, so the manual one-liner replaces the enriched wrapper the SDK installed. The fix, therefore, is not just to read all three arguments but to make sure your handler is the one place where enrichment happens, rather than an accidental downgrade of what the SDK already set up.

Step-by-Step Fix

The goal across these steps is a single enrichment helper that both your Vue handler and any future unhandledrejection listener can share, so every event that reaches your SDK carries an identical, searchable context shape. Build it incrementally: capture all three arguments, resolve a stable name, assemble a context object, and only then hand off to the SDK.

1. Declare the handler with the full three-argument signature

// main.js — capture all three arguments Vue provides
app.config.errorHandler = (err, instance, info) => {
  // err: the thrown Error; instance: component proxy; info: Vue lifecycle phase
  console.debug('[vue-error]', info, instance?.$options?.name);
};

2. Resolve a stable component name from the instance proxy

// resolve the registered/file name, falling back gracefully
function componentName(instance) {
  // __name is set by the SFC compiler; name is the explicit option
  return instance?.$options?.name || instance?.$?.type?.__name || 'anonymous';
}

3. Build a context object that carries the Vue info string

// map Vue's diagnostic data into a plain object for the SDK
function vueErrorContext(instance, info) {
  return { lifecycleHook: info, component: componentName(instance), propsKeys: Object.keys(instance?.$props || {}) };
}

4. Forward to Sentry with a tag and a dedicated context tab

// main.js — Sentry: tag by lifecycle phase, attach a vue context block
import * as Sentry from '@sentry/vue';

app.config.errorHandler = (err, instance, info) => {
  Sentry.withScope((scope) => {
    scope.setTag('vue.lifecycle', info);                 // groups issues by phase
    scope.setContext('vue', vueErrorContext(instance, info)); // adds a "vue" tab
    Sentry.captureException(err);                          // now fully enriched
  });
};

5. Or forward to Datadog RUM with custom attributes

// main.js — Datadog: addError with a context payload
import { datadogRum } from '@datadog/browser-rum';

app.config.errorHandler = (err, instance, info) => {
  // the second arg becomes searchable custom attributes on the error
  datadogRum.addError(err, { vue: vueErrorContext(instance, info), source: 'vue-errorHandler' });
};

6. Re-throw in development so the overlay still shows

// preserve local DX: log to console AND to the SDK in dev
if (import.meta.env.DEV) {
  const forward = app.config.errorHandler;
  app.config.errorHandler = (err, instance, info) => { forward(err, instance, info); console.error(err); };
}

How the handler enriches an event before the SDK capture callA vertical three-stage flow. The first stage is the errorHandler receiving err, instance and info. The second stage builds a context object with component name and lifecycle hook. The third stage calls captureException with the enriched error.err, instance, infobuild context objectcaptureException(err)

The difference this makes is the difference between an error list you can act on and one you cannot. Before enrichment, ten different component failures collapse into one indistinguishable group; after, each is tagged with its component and phase, so a spike in render function errors on CartSummary is instantly visible.

Raw capture versus enriched captureTwo panels compare the telemetry event. The before panel in red lists a message-only event with no component and hard triage. The after panel in green lists a named component, its lifecycle phase, and grouping by info.RAW CAPTUREmessage onlyno component namehard to triageENRICHEDcomponent namelifecycle phase taggrouped by info

Verification

Mount a component that throws during render, inject a spy handler, and assert that the SDK received both the error and the enriched context:

// verify the handler forwards err, component name, and info to the SDK
import { mount } from '@vue/test-utils';
import { defineComponent, h } from 'vue';
import { vi, expect, test } from 'vitest';

test('errorHandler enriches the capture call', () => {
  const capture = vi.fn();

  const Thrower = defineComponent({
    name: 'CartSummary',
    setup() { return () => { throw new Error('boom'); }; },
  });

  mount(Thrower, {
    global: {
      config: {
        errorHandler: (err, instance, info) => {
          capture(err, { component: instance?.$options?.name, info });
        },
      },
    },
  });

  expect(capture).toHaveBeenCalledTimes(1);
  const [err, ctx] = capture.mock.calls[0];
  expect(err.message).toBe('boom');
  expect(ctx.component).toBe('CartSummary');   // name resolved from the instance
  expect(ctx.info).toBe('render function');    // Vue's lifecycle phase string
});

A passing run proves three things at once: the handler receives all three arguments, the component name resolves off the instance proxy, and the info string matches Vue’s documented phase name — exactly the payload your captureException call needs. Assert on the context object rather than mocking the entire SDK: the SDK’s transport is its own concern, and stubbing captureException or datadogRum.addError keeps the test focused on the contract you own — that Vue’s diagnostic data survives the trip from the handler into the capture call. Run the same assertion against a component that throws inside a watch callback to confirm the info value shifts to "watcher callback", which is the behaviour that lets you split issues by phase in production.

Edge Cases & Gotchas

  • The instance can be null. Errors thrown before a component instance exists — during app-level plugin setup or in a render happening outside a component context — invoke the handler with instance === null. Always use optional chaining (instance?.$options) or your handler will itself throw and mask the original error.
  • Anonymous components report no name. A <script setup> SFC without an explicit name option only exposes __name when the compiler’s filename-based naming is enabled. If it is stripped in production, fall back to the first frame of the stack or to the info string alone rather than sending undefined.
  • Do not double-report with @sentry/vue. If you pass app to Sentry.init({ app }), the integration already installs its own errorHandler wrapper. Adding a manual captureException on top sends each error twice. Either let the integration handle it and add context via beforeSend, or opt out with Sentry.init({ app, attachErrorHandler: false }) and own the handler yourself.
  • The info string is not a stack frame. It is a coarse phase label, not a line number. Use it for grouping and tagging, but keep source maps configured so the stack itself still deminifies. See the parent guide for where errorHandler sits relative to onErrorCaptured.

FAQ

Why is my Sentry issue title just the error message with no component context?

Because your handler called Sentry.captureException(err) without a scope. captureException on its own only sees the Error object. Wrap it in Sentry.withScope and call scope.setContext('vue', …) and scope.setTag('vue.lifecycle', info) inside that scope, so the component name and phase attach to that specific event rather than leaking onto later unrelated events.

Does the info argument replace the stack trace for grouping?

No. Sentry and Datadog still fingerprint primarily on the stack trace and error type. The info string is supplementary — use it as a tag so you can filter or split an issue by lifecycle phase (for example, separating render function crashes from watcher callback failures), but it does not override stack-based grouping.

Should I still register a window unhandledrejection listener if I have this handler?

Yes. app.config.errorHandler only fires for errors Vue itself catches during rendering, lifecycle hooks, and watchers. Fire-and-forget promise rejections escape it entirely and must be captured separately. Wire both into the same enrichment helper so events share a consistent context shape regardless of origin.