diff --git a/.changeset/evals-run-detail-funnel-visibility.md b/.changeset/evals-run-detail-funnel-visibility.md
new file mode 100644
index 0000000000..e58ab0ff80
--- /dev/null
+++ b/.changeset/evals-run-detail-funnel-visibility.md
@@ -0,0 +1,15 @@
+---
+"@mcpjam/inspector": patch
+---
+
+The user-value chain is visible on `/evals` run detail when it has data
+
+The chain funnel was mounted on run detail but could not be seen on the runs where it was the only thing to show. Two gates decide whether the insight rail exists at all — `run-insight-rail.tsx`'s emptiness check and `run-detail-view.tsx`'s `hasInsightContent` — and both counted only the triage, goal-completion and groundedness cards. A run with a derived chain and no judge or triage output rendered no rail, so the funnel it contained was never drawn.
+
+Adding the chain card to those checks would have traded one bug for another, which is why the exclusion was deliberate and documented: the card is a truthy fragment whose two halves each suppress themselves from the inside, so counting the NODE would keep an otherwise-empty rail alive as a full-height column of dead space on every run with no insight content at all.
+
+So the gates now read a fact about the DATA instead. A probe mounted above every layout branch asks the same rollup query the funnel itself uses — `undefined` while loading, `null` for a run with no rollup, which is exactly the panel's own render condition — and reports one boolean that both gates consume. Convex de-duplicates identical subscriptions, so asking twice costs one query, and the probe carries the same `ErrorBoundary` the panels do: `useQuery` throws when the query is not deployed or when there is no `ConvexProvider`, and a probe that took the page down with it would be worse than the empty rail it exists to prevent. Undeployed reads as "no funnel", which is correct.
+
+The state starts `false`, so a run without a funnel never flashes an empty rail on the way to finding out.
+
+The probe's boundary does not file the dark-ship window with Sentry or PostHog. Until the query is deployed it throws on every run-detail visit, so an unconditional report would turn a documented, intended state into one issue and one event per run viewed — noise indistinguishable from a real regression at the moment one would matter most. `ErrorBoundary` gains an opt-in `isExpectedError` predicate for that, deliberately not a boolean: a boundary that suppressed everything would swallow the real bug it exists to surface. Only the two shapes Convex actually throws for an unavailable query are matched, `onError` still fires so the rail still closes, and a predicate that throws reports as normal.
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index c1d1b18285..1a486ed503 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -7,6 +7,7 @@ on:
branches:
- main
- "claude/mcp-benchmarks-v2-b0tw4b-**"
+ - "claude/uvc-mcp-eval-reporting-gyycwl**"
push:
branches: [main]
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index de3a377350..f78c869e35 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -5,10 +5,16 @@ on:
# `branches` filters on the PR's BASE. A stacked PR is based on the branch
# below it rather than on main, so with `main` alone every PR above the
# root of a stack merges without these tests having run against it once.
- # Drop the second pattern once the Connector Bench stack has landed.
+ # Drop each stack pattern once that stack has landed.
+ #
+ # The UVH pattern was added after this bit us: a test in the stack asserted
+ # a label's wording verbatim, a later PR in the same stack changed that
+ # wording, and the break sat unnoticed because every PR above the root was
+ # green on previews and review bots alone.
branches:
- main
- "claude/mcp-benchmarks-v2-b0tw4b-**"
+ - "claude/uvc-mcp-eval-reporting-gyycwl**"
push:
branches: [main]
diff --git a/mcpjam-inspector/client/src/components/evals/__tests__/run-insight-rail.test.tsx b/mcpjam-inspector/client/src/components/evals/__tests__/run-insight-rail.test.tsx
index a50a998e58..7e4a26d757 100644
--- a/mcpjam-inspector/client/src/components/evals/__tests__/run-insight-rail.test.tsx
+++ b/mcpjam-inspector/client/src/components/evals/__tests__/run-insight-rail.test.tsx
@@ -5,6 +5,7 @@ import {
RunAccuracyHeroBand,
RunDetailMetricsCharts,
RunInsightRail,
+ runHasInsightContent,
} from "../run-insight-rail";
import type { EvalIteration, EvalSuiteRun } from "../types";
@@ -129,10 +130,14 @@ describe("RunAccuracyHeroBand", () => {
/>,
);
- expect(screen.getByRole("heading", { name: /Run run-2/i })).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { name: /Run run-2/i }),
+ ).toBeInTheDocument();
expect(screen.getByText("Failed")).toBeInTheDocument();
expect(screen.getByText(/7 passed · 3 failed ·/)).toBeInTheDocument();
- const band = screen.getByRole("heading", { name: /Run run-2/i }).closest("section");
+ const band = screen
+ .getByRole("heading", { name: /Run run-2/i })
+ .closest("section");
expect(band).toHaveTextContent("Accuracy");
expect(band).toHaveTextContent("70%");
});
@@ -174,6 +179,50 @@ describe("RunInsightRail", () => {
screen.queryByRole("heading", { name: "Latency by test (p50 / p95)" }),
).not.toBeInTheDocument();
});
+
+ it("stays closed when the chain card is the ONLY thing passed and it has no data", () => {
+ // The reason the card was excluded from the emptiness check to begin
+ // with: the node is truthy even when both of its halves render nothing,
+ // so counting the node would leave a full-height column of dead space.
+ const { container } = render(
+ }
+ />,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ expect(screen.queryByTestId("chain-slot")).not.toBeInTheDocument();
+ });
+
+ it("opens for a run whose ONLY insight is its user-value chain", () => {
+ // UVH-IN5, and the bug this fixes: a run with a derived chain but no
+ // judge or triage output rendered no rail at all, so the chain was
+ // invisible on exactly the runs where it was the whole story.
+ render(
+ Funnel}
+ userValueChainHasContent
+ />,
+ );
+
+ expect(screen.getByTestId("chain-slot")).toBeInTheDocument();
+ });
+
+ it("still opens for other insight content when the chain has none", () => {
+ render(
+ Insights}
+ userValueChainCard={}
+ userValueChainHasContent={false}
+ />,
+ );
+
+ expect(screen.getByTestId("triage-slot")).toBeInTheDocument();
+ // The card is still mounted — it suppresses itself from the inside.
+ expect(screen.getByTestId("chain-slot")).toBeInTheDocument();
+ });
});
describe("RunDetailMetricsCharts", () => {
@@ -210,3 +259,49 @@ describe("RunDetailMetricsCharts", () => {
).toBeInTheDocument();
});
});
+
+describe("runHasInsightContent", () => {
+ // The OUTER half of the same decision `RunInsightRail` makes, and the one
+ // that runs first: it decides whether the band that wraps the rail exists at
+ // all. A rail that correctly opens is still never seen if this says no, so
+ // the rail's own tests cannot catch a regression here — they run inside a
+ // band that already rendered.
+ const none = {
+ serverQualityTriage: null,
+ goalCompletionPanel: null,
+ groundednessPanel: null,
+ actionableFindingsPanel: null,
+ hasStageFunnel: false,
+ };
+
+ it("is false when a run has nothing to show", () => {
+ expect(runHasInsightContent(none)).toBe(false);
+ });
+
+ it("is true for a run whose ONLY insight is its user-value chain", () => {
+ // The bug UVH-IN5 exists to fix. Every other member here is absent, so
+ // this is the case that was invisible: the chain is the report card of
+ // what the eval measured, and it was hidden on exactly the runs where it
+ // was the whole story.
+ expect(runHasInsightContent({ ...none, hasStageFunnel: true })).toBe(true);
+ });
+
+ it.each([
+ ["serverQualityTriage"],
+ ["goalCompletionPanel"],
+ ["groundednessPanel"],
+ ["actionableFindingsPanel"],
+ ])("is true for a run whose only insight is %s", (key) => {
+ expect(runHasInsightContent({ ...none, [key]: })).toBe(true);
+ });
+
+ it("reads the DATA, never the node", () => {
+ // The chain card is a fragment whose two halves each self-suppress while
+ // the fragment itself stays truthy. Passing the node would keep an
+ // otherwise-empty band alive as a full-height column of dead space, which
+ // is why the signature takes a boolean about the data instead.
+ expect(runHasInsightContent({ ...none, hasStageFunnel: false })).toBe(
+ false,
+ );
+ });
+});
diff --git a/mcpjam-inspector/client/src/components/evals/run-detail-view.tsx b/mcpjam-inspector/client/src/components/evals/run-detail-view.tsx
index f48f7e83e8..b6318b6cfa 100644
--- a/mcpjam-inspector/client/src/components/evals/run-detail-view.tsx
+++ b/mcpjam-inspector/client/src/components/evals/run-detail-view.tsx
@@ -45,7 +45,10 @@ import {
type JudgeCase,
} from "./goal-completion-presentation";
import { RunInsightBand, type InsightSeverity } from "./run-insight-band";
-import { SuiteRunStageFunnelPanel } from "@/components/shared/user-value-chain/StageFunnelPanels";
+import {
+ SuiteRunStageFunnelAvailability,
+ SuiteRunStageFunnelPanel,
+} from "@/components/shared/user-value-chain/StageFunnelPanels";
import { ExplanatoryFlowOptIn } from "@/components/shared/usage-insights/ExplanatoryFlowOptIn";
import type { InsightsScope } from "@/hooks/useUsageInsights";
import { useAvailableModels } from "@/hooks/use-available-models";
@@ -63,6 +66,7 @@ import { HostChip } from "@/components/hosts/host-chip";
import {
RunAccuracyHeroBand,
RunInsightRail,
+ runHasInsightContent,
shouldShowRunAccuracyHero,
type RunTrendPoint,
} from "./run-insight-rail";
@@ -576,6 +580,30 @@ export function RunDetailView({
const embeddedInResultsSplit = hideKpiStrip;
+ /**
+ * Whether this run has a stage funnel to draw, reported by the probe below.
+ *
+ * Stored WITH the run it describes, and read only when that run is the one
+ * on screen. This component is reused across runs by the run selector, so a
+ * bare boolean would survive a switch and a stale `true` would open an empty
+ * rail on the run you moved to. Starting empty also means a run without a
+ * funnel never flashes one on the way to finding out.
+ */
+ const [stageFunnelFor, setStageFunnelFor] = useState<{
+ suiteRunId: string | undefined;
+ hasFunnel: boolean;
+ }>({ suiteRunId: undefined, hasFunnel: false });
+
+ const handleStageFunnelAvailability = useCallback(
+ (suiteRunId: string | undefined, hasFunnel: boolean) =>
+ setStageFunnelFor({ suiteRunId, hasFunnel }),
+ [],
+ );
+
+ const hasStageFunnel =
+ stageFunnelFor.suiteRunId === selectedRunDetails._id &&
+ stageFunnelFor.hasFunnel;
+
const serverQualityTriage =
selectedRunDetails.status === "completed" && !serverQualityUnavailable ? (
);
+ /**
+ * Mounted unconditionally, and deliberately NOT inside the rail or the band
+ * it informs — it answers whether those should open, so it has to exist
+ * before they do. Renders nothing; costs one query, which Convex shares with
+ * the panel's own subscription.
+ */
+ const stageFunnelProbe = (
+
+ );
+
const insightRail = (
);
- const hasInsightContent = Boolean(
- serverQualityTriage ||
- goalCompletionPanel ||
- groundednessPanel ||
- actionableFindingsPanel,
- );
+ /**
+ * The chain counts toward "is there anything to show" — but only when it
+ * actually has a funnel to draw.
+ *
+ * Both gates below read this ONE boolean, and it comes from a probe rather
+ * than from the panel, because the panel cannot answer: neither gate mounts
+ * it until they have already decided to open. Counting the card itself
+ * instead would keep a rail alive on every run with no insight content at
+ * all, since the node is truthy whether or not it renders anything — which
+ * is why it was excluded from these checks in the first place, and why the
+ * fix has to be data-driven rather than a matter of adding the node.
+ */
+ const hasInsightContent = runHasInsightContent({
+ serverQualityTriage,
+ goalCompletionPanel,
+ groundednessPanel,
+ actionableFindingsPanel,
+ hasStageFunnel,
+ });
const triageFixCount = useMemo(
() =>
@@ -1041,6 +1096,9 @@ export function RunDetailView({
omitIterationList && "px-3 py-3",
)}
>
+ {/* Renders nothing. Sits above every layout branch below because all of
+ them gate on the answer it reports. */}
+ {stageFunnelProbe}
{onExportTraces || pluginSubmissionVersions.length > 0 || onShare ? (
// Always-on run-level actions — placed here (not the accuracy hero) so
// they survive the folded run-detail layout that hides the hero.
diff --git a/mcpjam-inspector/client/src/components/evals/run-insight-rail.tsx b/mcpjam-inspector/client/src/components/evals/run-insight-rail.tsx
index 904f7ccea5..e150580f0a 100644
--- a/mcpjam-inspector/client/src/components/evals/run-insight-rail.tsx
+++ b/mcpjam-inspector/client/src/components/evals/run-insight-rail.tsx
@@ -180,8 +180,8 @@ export function RunAccuracyHeroBand({
stats.total > 0
? normalizeRunPassRatePercent(stats.passRate)
: run.summary
- ? normalizeRunPassRatePercent(run.summary.passRate)
- : null;
+ ? normalizeRunPassRatePercent(run.summary.passRate)
+ : null;
const trendChips = useMemo(() => {
if (runTrendData.length < 2) return { points: [], hiddenCount: 0 };
@@ -251,10 +251,7 @@ export function RunAccuracyHeroBand({
{runClient || runServers.length > 0 ? (
+ Last {RUN_TREND_CHIP_LIMIT} of {runTrendData.length}
+
+ ) : null}
+
+
+ {trendChips.points.map((point) => (
+
+ ))}
+
-
- ) : null;
+ ) : null;
// With run identity: title/stats and recent runs share one row; accuracy on the right.
if (includeRunIdentity) {
@@ -380,8 +378,8 @@ export function shouldShowRunAccuracyHero({
stats.total > 0
? normalizeRunPassRatePercent(stats.passRate)
: run.summary
- ? normalizeRunPassRatePercent(run.summary.passRate)
- : null;
+ ? normalizeRunPassRatePercent(run.summary.passRate)
+ : null;
return passRatePercent !== null;
}
@@ -411,12 +409,54 @@ export function RunDetailMetricsCharts({
);
}
+/**
+ * Whether the run-detail INSIGHT BAND should exist at all.
+ *
+ * The outer half of the same decision `RunInsightRail` makes below, and the
+ * one that runs first: the band wraps the rail, so a rail that correctly
+ * decides to open is never seen if this says no. They are separate conditions
+ * because they count different things — the band also counts the actionable-
+ * findings panel, which the rail does not render — but they must agree about
+ * the chain, and this is where that agreement is written down.
+ *
+ * `hasStageFunnel` is the member worth naming. Like `userValueChainHasContent`
+ * below it is a fact about the DATA, not about a node: the chain card is a
+ * fragment whose two halves each self-suppress while the fragment itself stays
+ * truthy, so counting the node would keep an otherwise-empty band alive as
+ * dead space. Dropping it, conversely, hides the band on exactly the runs
+ * whose chain is the only thing there is to show — the bug this lane exists to
+ * fix, and one the rail's own tests cannot catch from inside a band that never
+ * rendered.
+ */
+export function runHasInsightContent({
+ serverQualityTriage,
+ goalCompletionPanel,
+ groundednessPanel,
+ actionableFindingsPanel,
+ hasStageFunnel,
+}: {
+ serverQualityTriage?: ReactNode;
+ goalCompletionPanel?: ReactNode;
+ groundednessPanel?: ReactNode;
+ actionableFindingsPanel?: ReactNode;
+ hasStageFunnel?: boolean;
+}): boolean {
+ return Boolean(
+ serverQualityTriage ||
+ goalCompletionPanel ||
+ groundednessPanel ||
+ actionableFindingsPanel ||
+ hasStageFunnel,
+ );
+}
+
/** Right column: AI insights only. */
export function RunInsightRail({
triageCard,
goalCompletionCard,
groundednessCard,
userValueChainCard,
+ userValueChainHasContent = false,
className,
embedded = false,
}: {
@@ -433,18 +473,38 @@ export function RunInsightRail({
* diagram beside it is bought per pass and waits for a click. Same traces,
* different price, so different affordance.
*
- * Deliberately NOT part of the emptiness check below. Both halves render
- * nothing of their own when there is no derived chain and no analyzable
- * cohort, and the node itself is truthy either way — counting it would keep
- * an otherwise-empty rail alive as a full-height column of dead space on
- * every run with no insight content at all.
+ * The NODE is deliberately still not part of the emptiness check below.
+ * Both halves render nothing of their own when there is no derived chain and
+ * no analyzable cohort, yet the node itself is truthy either way — counting
+ * it would keep an otherwise-empty rail alive as a full-height column of
+ * dead space on every run with no insight content at all.
+ *
+ * What the check reads instead is `userValueChainHasContent`, a fact about
+ * the DATA rather than about the node. That is what lets a run whose only
+ * insight is its chain open the rail — the case this card was previously
+ * invisible on — without reintroducing the empty column.
*/
userValueChainCard?: ReactNode;
+ /**
+ * Whether the chain card will actually draw something.
+ *
+ * Supplied by the caller, which asks a probe mounted outside this rail: the
+ * rail cannot ask the card, because it does not mount the card until it has
+ * already decided to exist.
+ */
+ userValueChainHasContent?: boolean;
className?: string;
/** Flush layout inside the run-detail split (shared dividers, no card gaps). */
embedded?: boolean;
}) {
- if (!triageCard && !goalCompletionCard && !groundednessCard) return null;
+ if (
+ !triageCard &&
+ !goalCompletionCard &&
+ !groundednessCard &&
+ !userValueChainHasContent
+ ) {
+ return null;
+ }
return (
,
);
expect(getByTestId("sibling").textContent).toBe("the rest of the page");
});
});
+
+describe("SuiteRunStageFunnelAvailability — the probe that opens the rail", () => {
+ it("reports true only when the rollup actually answered", () => {
+ convex.useQuery.mockReturnValue(SUMMARY);
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ expect(onChange).toHaveBeenCalledWith("run-1", true);
+ });
+
+ it.each([
+ ["still loading", undefined],
+ ["a run with no rollup", null],
+ ])("reports false while %s", (_label, value) => {
+ convex.useQuery.mockReturnValue(value);
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ expect(onChange).toHaveBeenCalledWith("run-1", false);
+ });
+
+ it("reports false and renders nothing when the query throws", () => {
+ // The dark-ship state. Undeployed must read as "no funnel", and the probe
+ // must not take the run-detail page down with it.
+ queryThrows();
+ const onChange = vi.fn();
+ const { container } = render(
+
+ the rest of the page
+
+
,
+ );
+ expect(onChange).toHaveBeenCalledWith("run-1", false);
+ expect(container.textContent).toBe("the rest of the page");
+ });
+
+ it("files NOTHING with Sentry/PostHog for the dark-ship window", () => {
+ // The probe is mounted on every run-detail visit and the query is
+ // deliberately undeployed, so an unconditional boundary report turns the
+ // intended state into one issue and one event per run VIEWED — noise
+ // indistinguishable from a real regression, at the moment one would
+ // matter most.
+ queryThrows();
+ render(
+ ,
+ );
+ expect(reportBoundaryError).not.toHaveBeenCalled();
+ });
+
+ it("DOES file a failure that is not one of the two expected shapes", () => {
+ // The suppression is a predicate, not a mute button: a probe that stopped
+ // reporting everything would swallow the real bug it exists to surface.
+ convex.useQuery.mockImplementation(() => {
+ throw new Error("TypeError: cannot read properties of undefined");
+ });
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ expect(reportBoundaryError).toHaveBeenCalledTimes(1);
+ // And the rail still closes either way.
+ expect(onChange).toHaveBeenCalledWith("run-1", false);
+ });
+
+ it("clears a previous answer when the SAME run's probe then fails", () => {
+ // The boundary key re-arms the probe across runs, but a query that throws
+ // after answering for the run still on screen renders the fallback
+ // silently. Without `onError` the caller would keep the last good `true`
+ // and hold the rail open over a funnel that is no longer there.
+ convex.useQuery.mockReturnValue(SUMMARY);
+ const onChange = vi.fn();
+ const { rerender } = render(
+ ,
+ );
+ expect(onChange).toHaveBeenLastCalledWith("run-1", true);
+
+ queryThrows();
+ rerender(
+ ,
+ );
+ expect(onChange).toHaveBeenLastCalledWith("run-1", false);
+ });
+
+ it("names the run each answer is about, so a stale one is detectable", () => {
+ // The run selector reuses one view across runs. An answer that did not
+ // name its run could not be told apart from the previous run's, and a
+ // stale `true` would open an empty rail on the run you switched to.
+ convex.useQuery.mockReturnValue(SUMMARY);
+ const onChange = vi.fn();
+ const { rerender } = render(
+ ,
+ );
+ expect(onChange).toHaveBeenLastCalledWith("run-1", true);
+
+ convex.useQuery.mockReturnValue(null);
+ rerender(
+ ,
+ );
+ expect(onChange).toHaveBeenLastCalledWith("run-2", false);
+ });
+
+ it("re-arms after a failing run: a later run is still probed", () => {
+ // An ErrorBoundary that has caught stays in its fallback for the life of
+ // the element, so the boundary is keyed by run. Without the key, one
+ // transient failure would hide the chain on every run after it.
+ queryThrows();
+ const onChange = vi.fn();
+ const { rerender } = render(
+ ,
+ );
+ // The failing run reports "no funnel" rather than staying silent.
+ expect(onChange).toHaveBeenLastCalledWith("run-1", false);
+
+ convex.useQuery.mockReturnValue(SUMMARY);
+ rerender(
+ ,
+ );
+ expect(onChange).toHaveBeenCalledWith("run-2", true);
+ });
+});
+
+describe("isConvexQueryUnavailable", () => {
+ it.each([
+ ["an undeployed function", "Could not find public function for 'x:y'"],
+ ["no ConvexProvider", "Could not find Convex client!"],
+ ])("recognises %s", (_label, message) => {
+ expect(isConvexQueryUnavailable(new Error(message))).toBe(true);
+ });
+
+ it.each([
+ ["a real bug", "Cannot read properties of undefined (reading 'stages')"],
+ ["an auth refusal", "Authenticated user required"],
+ ["an empty message", ""],
+ ])("does NOT recognise %s", (_label, message) => {
+ expect(isConvexQueryUnavailable(new Error(message))).toBe(false);
+ });
+
+ it("survives an error with no message at all", () => {
+ // The predicate runs inside a boundary that is already handling a failure;
+ // throwing from here would be the second failure, at the worst moment.
+ expect(
+ isConvexQueryUnavailable({ message: undefined } as unknown as Error),
+ ).toBe(false);
+ });
+});
diff --git a/mcpjam-inspector/client/src/components/ui/__tests__/error-boundary.test.tsx b/mcpjam-inspector/client/src/components/ui/__tests__/error-boundary.test.tsx
index fe21f7b931..fc38a31a32 100644
--- a/mcpjam-inspector/client/src/components/ui/__tests__/error-boundary.test.tsx
+++ b/mcpjam-inspector/client/src/components/ui/__tests__/error-boundary.test.tsx
@@ -106,6 +106,77 @@ describe("ErrorBoundary", () => {
expect(screen.getByText("recovered")).toBeInTheDocument();
});
+ it("does NOT report an error its boundary declared expected", () => {
+ // UVH-IN5. A dark-shipped query throws on a page users open repeatedly,
+ // so an unconditional report turns a documented, intended state into one
+ // Sentry issue and one PostHog event per visit.
+ render(
+ error.message === "kaboom"}
+ >
+
+ ,
+ );
+
+ expect(reportBoundaryError).not.toHaveBeenCalled();
+ });
+
+ it("still reports an error the SAME boundary did not expect", () => {
+ // The predicate is the whole point: a boundary that suppressed everything
+ // would swallow the real bug it exists to surface.
+ render(
+ error.message === "something else"}
+ >
+
+ ,
+ );
+
+ expect(reportBoundaryError).toHaveBeenCalledTimes(1);
+ });
+
+ it("still calls onError for an expected error — telemetry only is suppressed", () => {
+ // The probe that motivated this uses `onError` to close its rail. Losing
+ // that alongside the reporting would trade a noisy alarm for a stuck UI.
+ const onError = vi.fn();
+ render(
+ true}
+ onError={onError}
+ >
+
+ ,
+ );
+
+ expect(reportBoundaryError).not.toHaveBeenCalled();
+ expect(onError).toHaveBeenCalledTimes(1);
+ });
+
+ it("reports normally when the predicate itself throws", () => {
+ // Fail loud, not silent: a broken predicate must not become a way to lose
+ // errors, which is the one outcome worse than the noise this suppresses.
+ render(
+ {
+ throw new Error("predicate is broken");
+ }}
+ >
+
+ ,
+ );
+
+ expect(reportBoundaryError).toHaveBeenCalledTimes(1);
+ expect((reportBoundaryError.mock.calls[0][0] as Error).message).toBe(
+ "kaboom",
+ );
+ });
+
it("renders the default UI and does not report when nothing throws", () => {
render(
diff --git a/mcpjam-inspector/client/src/components/ui/error-boundary.tsx b/mcpjam-inspector/client/src/components/ui/error-boundary.tsx
index 386e53b794..c5f4915100 100644
--- a/mcpjam-inspector/client/src/components/ui/error-boundary.tsx
+++ b/mcpjam-inspector/client/src/components/ui/error-boundary.tsx
@@ -15,6 +15,26 @@ interface ErrorBoundaryProps {
* failures you expect to triage separately.
*/
name?: string;
+ /**
+ * Opt one boundary out of reporting for errors it EXPECTS, by predicate.
+ *
+ * Deliberately not a boolean. A boundary that suppressed everything would
+ * also swallow the real bug it was meant to surface, and the whole point of
+ * reporting here rather than at the mount sites is that a `fallback={null}`
+ * cannot silently eat one. A predicate keeps the exception as narrow as the
+ * call site can describe it: match the shape you know is expected, and
+ * anything else still reports exactly as before.
+ *
+ * The case this exists for is a dark-shipped query. `useQuery` throws while
+ * the function is not deployed yet, and a probe mounted on a page a user
+ * visits repeatedly turns a DOCUMENTED, intended state into one Sentry issue
+ * and one PostHog event per visit — noise that is indistinguishable from a
+ * real regression precisely when a real regression would matter most.
+ *
+ * `onError` still fires, and the fallback still renders: this suppresses the
+ * telemetry, never the handling.
+ */
+ isExpectedError?: (error: Error) => boolean;
}
interface ErrorBoundaryState {
@@ -27,7 +47,8 @@ interface ErrorBoundaryState {
*
* Every caught error is reported to Sentry + PostHog regardless of which
* fallback renders — "silent to the user" is a UI choice, never a telemetry
- * one.
+ * one. The single exception is `isExpectedError`, an opt-in predicate for a
+ * boundary that can name a failure shape it genuinely expects; see below.
*
* Fallback semantics:
* - `fallback={null}` → render nothing on error (intentional silence; e.g.
@@ -53,13 +74,32 @@ export class ErrorBoundary extends React.Component<
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
- console.error("ErrorBoundary caught an error:", error, errorInfo);
- // Reported here rather than at the ~21 mount sites: a boundary rendering
- // `fallback={null}` used to swallow its error entirely.
- reportBoundaryError(error, errorInfo, this.props.name);
+ // An expected error is still worth a breadcrumb — it just is not a fault,
+ // so it goes to `debug` rather than shouting on the console once per visit.
+ const expected = this.isExpected(error);
+ if (expected) {
+ console.debug("ErrorBoundary caught an expected error:", error);
+ } else {
+ console.error("ErrorBoundary caught an error:", error, errorInfo);
+ // Reported here rather than at the ~21 mount sites: a boundary rendering
+ // `fallback={null}` used to swallow its error entirely.
+ reportBoundaryError(error, errorInfo, this.props.name);
+ }
+ // Outside the branch on purpose: suppressing telemetry must not also
+ // suppress the caller's handling of the failure.
this.props.onError?.(error, errorInfo);
}
+ /** A throwing predicate must not stop the error from being reported. */
+ private isExpected(error: Error): boolean {
+ if (!this.props.isExpectedError) return false;
+ try {
+ return this.props.isExpectedError(error) === true;
+ } catch {
+ return false;
+ }
+ }
+
handleReset = () => {
this.setState({ hasError: false, error: null });
};