Skip to content
result-rpc
Esc
navigateopen⌘Jpreview

Observability

Four taps, one per tier — a redaction-safe wire stream, shell reactions, and policy-aware server hooks.

The footgun: a Sentry event that says TRPCClientError: UNAUTHORIZED with a stack that points into library code, and a dashboard you reconstruct from string codes after the fact. When observability is table stakes, “which operation, which tag, who owned it, what did the server actually see” cannot be archaeology.

Every observable moment is already a value at a known choke point, so observability is one structured stream plus adapters — never an integration that fights the framework. Four taps, one per tier:

// 1. Wire: every call, retry, claim — paths, tags, timing; never values.
const client = createBrowserClient({
  contract,
  transport,
  onEvent: (event) =>
    Sentry.addBreadcrumb({
      category: `rpc.${event.type}`,
      message: "path" in event ? event.path : event.type,
      level: event.type === "failure" ? "warning" : "info",
      data: event,
    }),
});
// event: call | success | failure | retry | skew
//      | claimed  ← a pausing shell held it: { path, tag, owner, effect: "pause" }

// 2. Ownership: a shell's reaction is a reporting moment.
const AuthShell = layerShell(AuthLayer, {
  from: DefectShell,
  select: (client: AppClient) => client.auth.me,
  onError: (error) => {
    Sentry.captureMessage(`signed out: ${error._tag}`, "info");
    redirect("/login");
  },
});

// 3. Server, declared errors: policy included, so severity routes the sink.
createFetchHandler({
  router,
  onError: ({ error, policy, procedurePath, httpStatus }) => {
    metrics.increment(error._tag, { status: httpStatus });
    if (policy?.severity === "error") Sentry.captureMessage(error._tag);
  },
  // 4. Server, defects: the only place causes and stacks exist.
  onInternalError: ({ incidentId, cause, procedurePath, phase }) => {
    Sentry.captureException(cause, { tags: { incidentId, procedurePath, phase } });
  },
});

The wire stream is redaction-safe by construction: events carry paths, tags, durations, owners — never inputs or outputs — so forwarding it verbatim to a third-party tracker is not a data decision.

onEvent is a synchronous observation tap, so keep adapters small and move expensive delivery off the call path. React subscriptions do not open during render: the stream starts when the observer subscribes after commit, including under StrictMode.

For inline observation of a single Result, the tap combinators return the original value unchanged:

tapError(await client.doc.rename(input), (error) => log.warn(error._tag));
// also: tap(result, fn), tapBoth(result, { ok, error })

Was this page helpful?