Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 7 additions & 12 deletions apps/cloud/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { AutumnProvider } from "autumn-js/react";
import { isValidOrgSlug } from "@executor-js/api";
import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting";
import { createSentryFrontendErrorReporter } from "@executor-js/react/api/error-reporting";
import { AnalyticsProvider, type AnalyticsClient } from "@executor-js/react/api/analytics";
import { ExecutorProvider } from "@executor-js/react/api/provider";
import { OrganizationProvider } from "@executor-js/react/api/organization-context";
Expand Down Expand Up @@ -73,20 +73,15 @@ const analyticsClient: AnalyticsClient | undefined =
? (name, properties) => posthog.capture(name, properties)
: undefined;

const captureFrontendError: FrontendErrorReporter = (error, context) => {
// Shared with the desktop renderer: the factory normalizes the reported value
// to a real Error (handed an Effect Cause, Sentry has no message to title or
// group on) and owns the executor.ui tags. Only the transport differs.
const captureFrontendError = createSentryFrontendErrorReporter((error, applyScope) => {
Sentry.captureException(error, (scope) => {
scope.setTag("executor.ui.surface", context.surface);
scope.setTag("executor.ui.action", context.action);
scope.setTag("executor.ui.severity", context.severity ?? "error");
scope.setContext("executor.ui", {
surface: context.surface,
action: context.action,
message: context.message,
metadata: context.metadata,
});
applyScope(scope);
return scope;
});
};
});

function NotFoundPage() {
return (
Expand Down
132 changes: 132 additions & 0 deletions e2e/cloud/frontend-error-reporting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Cloud (browser): a failed API request is reported as a titled error.
//
// The console reports handled UI failures to the crash reporter, and every
// producer of one starts from an Effect `Cause` — a plain object with no name,
// message or stack. Handed that directly, the reporter has nothing to title
// the report with, so it files a message-less one and groups it on the
// reporting frame: unrelated frontend failures all land in a single nameless
// bucket that says only which function did the reporting, never what broke.
//
// The report is the product surface here, so this scenario reads it the way
// the outside world does. The browser SDK is configured to POST its envelopes
// same-origin (`tunnel`), so the suite intercepts that request and asserts on
// the payload the page actually tried to send.
import { expect } from "@effect/vitest";
import { Effect } from "effect";

import { scenario } from "../src/scenario";
import { Browser, Target } from "../src/services";
import { revisit, visit } from "../src/surfaces/browser";

type ReportedException = {
readonly type?: string;
readonly value?: string;
// The reporter marks an exception `synthetic` when it was handed something
// that was not a real error and had to invent a stack for it — the stack of
// whatever frame did the reporting.
readonly mechanism?: { readonly synthetic?: boolean };
};

type ReportedEvent = {
readonly tags?: Record<string, string>;
readonly exception?: { readonly values?: ReadonlyArray<ReportedException> };
};

/**
* An envelope is newline-delimited JSON — a header, then `{type}` / payload
* pairs. Only the error payloads matter here and they are the ones carrying
* `exception`, so pick those out rather than modelling the whole format.
*/
const errorEventsIn = (body: string): ReadonlyArray<ReportedEvent> =>
body
.split("\n")
.filter((line) => line.trim().startsWith("{"))
.flatMap((line) => {
try {
const parsed = JSON.parse(line) as ReportedEvent;
return parsed.exception ? [parsed] : [];
} catch {
return [];
}
});

scenario(
"Frontend errors · a failed API request is reported with a real message",
{ timeout: 120_000 },
Effect.gen(function* () {
const browser = yield* Browser;
const target = yield* Target;
const identity = yield* target.newIdentity();

yield* browser.session(identity, async ({ page, step }) => {
const reports: Array<ReportedEvent> = [];
await page.route("**/api/sentry-tunnel*", async (route) => {
reports.push(...errorEventsIn(route.request().postData() ?? ""));
await route.fulfill({ status: 200, body: "" });
});

// Everything the UI reports about a request it made itself.
const reportedFailures = (): ReadonlyArray<ReportedException> =>
reports
.filter((event) => event.tags?.["executor.ui.surface"] === "api_client")
.flatMap((event) => event.exception?.values ?? []);

await step("Open the integrations console", async () => {
await visit(page, "/integrations");
await page.getByRole("button", { name: "Connect" }).first().waitFor();
});

let faulted = 0;
await step("Reload it with the integrations API failing", async () => {
await page.route("**/api/integrations", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
faulted += 1;
await route.fulfill({
status: 500,
contentType: "text/plain",
body: "upstream exploded",
});
});
await revisit(page);
});

expect(faulted, "the integrations request really did fail").toBeGreaterThan(0);

// The report must name the failure. Reports are sent as the page
// notices failures, so wait for one rather than sleeping.
await expect
.poll(() => reportedFailures().map((failure) => failure.value ?? ""), {
message: "the reported failure says which request failed, and how",
timeout: 20_000,
})
.toContainEqual(expect.stringMatching(/500 .*\/api\/integrations/));

for (const failure of reportedFailures()) {
// A report with no message is the bug: it cannot be titled, so it
// groups on the reporting frame and swallows every other failure.
expect(failure.value ?? "", "every report carries a message").not.toBe("");
expect(failure.type ?? "", "every report carries an error name").not.toBe("");
// What a reporter falls back to when it is handed something that is
// not an error at all — the shape every message-less report had.
expect(failure.value ?? "", "no report is a bag of keys").not.toMatch(
/captured as exception with keys/,
);
// The other half of the bug, and the half a readable message can hide:
// handed a non-error, the reporter still has no stack of its own to
// group on and invents one from the reporting frame, so unrelated
// failures keep merging into a single bucket. Only a real error clears
// this flag.
expect(
failure.mechanism?.synthetic ?? false,
"the report carries the failure's own stack, not the reporter's frame",
).toBe(false);
}

await page.unroute("**/api/integrations");
await page.unroute("**/api/sentry-tunnel*");
});
}),
);
15 changes: 14 additions & 1 deletion e2e/setup/cloud.globalsetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,21 @@ const optionalCloudEnv = (): Record<string, string> => {
const env: Record<string, string> = {
SENTRY_OTEL_VERIFY: "true",
SENTRY_OTEL_LOG_PAYLOAD: "true",
// Boot the BROWSER crash reporter too, so what the frontend actually
// reports is observable to a scenario. Production always has this set;
// without it the reporter the app wires into ExecutorProvider is a no-op
// and "the frontend reports nothing at all" looks identical to health.
// Nothing leaves the machine: the SDK is configured with
// `tunnel: "/api/sentry-tunnel"`, so envelopes are POSTed same-origin,
// and that route 204s unless a server-side SENTRY_DSN is configured.
VITE_PUBLIC_SENTRY_DSN: "https://e2epublickey@ingest.e2e.invalid/1",
};
for (const key of ["SENTRY_DSN", "SENTRY_OTEL_LOG_PAYLOAD", "SENTRY_OTEL_VERIFY"]) {
for (const key of [
"SENTRY_DSN",
"SENTRY_OTEL_LOG_PAYLOAD",
"SENTRY_OTEL_VERIFY",
"VITE_PUBLIC_SENTRY_DSN",
]) {
const value = process.env[key];
if (value) env[key] = value;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsgo --noEmit",
"typecheck:slow": "tsc --noEmit",
"routes:gen": "bun scripts/gen-routes.ts"
Expand Down
115 changes: 115 additions & 0 deletions packages/app/src/crash-reporting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { afterEach, describe, expect, it } from "@effect/vitest";
// oxlint-disable-next-line executor/no-vitest-import -- boundary: vi.mock must come from vitest itself for mock hoisting to resolve
import { vi } from "vitest";
import * as Cause from "effect/Cause";

// oxlint-disable-next-line executor/no-error-constructor, executor/no-redundant-error-factory -- boundary: fixture for the adapter that turns raw thrown values into crash-report Errors
const rawError = (message: string): Error => new Error(message);

type Scope = {
readonly setTag: (key: string, value: string) => unknown;
readonly setContext: (key: string, value: Record<string, unknown> | null) => unknown;
};

const captured: Array<{
readonly error: unknown;
readonly tags: Record<string, string>;
readonly contexts: Record<string, Record<string, unknown> | null>;
}> = [];

vi.mock("@sentry/browser", () => ({
init: () => {},
captureException: (error: unknown, configure: (scope: Scope) => Scope) => {
const tags: Record<string, string> = {};
const contexts: Record<string, Record<string, unknown> | null> = {};
configure({
setTag: (key, value) => (tags[key] = value),
setContext: (key, value) => (contexts[key] = value),
});
captured.push({ error, tags, contexts });
return "event-id";
},
}));

const desktopBridge = {
executor: {
getCrashReporting: async () => ({
dsn: "https://public@sentry.invalid/1",
release: "0.0.0-test",
environment: "test",
runId: "run-test",
}),
},
};

const uiContext = { surface: "integrations", action: "connect" } as const;

afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
captured.length = 0;
});

/**
* The renderer used to pass no reporter at all, so handled UI errors fell
* through to `globalThis.reportError`. Sentry's global `onerror` integration
* then filed them as UNHANDLED crashes, titled from a `Data.TaggedError` with
* no `message` field — `FrontendHandledError: No error message` — with the
* surface/action context dropped entirely.
*/
describe("renderer handled-error reporting", () => {
it("falls back to a global error event before Sentry is initialized", async () => {
const reported: Array<unknown> = [];
vi.stubGlobal("reportError", (error: unknown) => {
reported.push(error);
});

const { reportRendererHandledError } = await import("./crash-reporting");
reportRendererHandledError(Cause.die(rawError("connect failed")), uiContext);

expect(reported).toHaveLength(1);
// Nothing is lost in builds that never get a DSN: the fallback still
// carries the failure text rather than an empty message.
expect((reported[0] as Error).message).toContain("connect failed");
});

it("captures handled errors through Sentry once the desktop bridge supplies a DSN", async () => {
vi.stubGlobal("window", desktopBridge);

const module = await import("./crash-reporting");
module.initDesktopCrashReporting();

await vi.waitFor(() => {
module.reportRendererHandledError(Cause.die(rawError("connect failed")), uiContext);
expect(captured.length).toBeGreaterThan(0);
});

const event = captured[0]!;
// A real Error, so the event has a title and groups on the failure rather
// than on whichever minified frame reported it.
expect(event.error).toBeInstanceOf(Error);
expect((event.error as Error).message).toBe("connect failed");
expect(event.tags["executor.ui.surface"]).toBe("integrations");
expect(event.tags["executor.ui.action"]).toBe("connect");
expect(event.contexts["executor.ui"]).toMatchObject({
surface: "integrations",
action: "connect",
});
});

it("stays on the fallback when the desktop bridge is absent", async () => {
vi.stubGlobal("window", {});
const reported: Array<unknown> = [];
vi.stubGlobal("reportError", (error: unknown) => {
reported.push(error);
});

const module = await import("./crash-reporting");
module.initDesktopCrashReporting();
module.reportRendererHandledError("plain failure", uiContext);

expect(captured).toHaveLength(0);
expect(reported).toHaveLength(1);
expect((reported[0] as Error).message).toContain("plain failure");
});
});
31 changes: 28 additions & 3 deletions packages/app/src/crash-reporting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,19 @@
* everywhere else the bridge is absent (or returns null in DSN-less builds)
* and Sentry is never imported, let alone initialized.
*
* Handled UI errors already flow through `globalThis.reportError` (see
* packages/react error-reporting), which Sentry's global handlers pick up
* once initialized — no reporter rewiring needed.
* Handled UI errors are reported through `reportRendererHandledError`, which
* the root route hands to `ExecutorProvider`. Letting them fall through to
* `globalThis.reportError` instead — as this module used to — filed them via
* Sentry's global `onerror` handler, i.e. as UNHANDLED crashes, with the
* surface/action context dropped on the floor. Until the DSN arrives (and in
* every build without one) the global-event fallback still applies, so nothing
* is lost in self-host.
*/
import {
createSentryFrontendErrorReporter,
reportViaGlobalErrorEvent,
type FrontendErrorReporter,
} from "@executor-js/react/api/error-reporting";

interface CrashReportingConfig {
readonly dsn: string;
Expand All @@ -23,6 +32,16 @@ interface CrashReportingBridge {
readonly getCrashReporting?: () => Promise<CrashReportingConfig | null>;
}

let initializedReporter: FrontendErrorReporter | null = null;

/**
* The reporter the renderer's `ExecutorProvider` uses. Stable identity, so it
* can be passed as a prop, and safe to call before (or without) Sentry init.
*/
export const reportRendererHandledError: FrontendErrorReporter = (error, context) => {
(initializedReporter ?? reportViaGlobalErrorEvent)(error, context);
};

export const initDesktopCrashReporting = (): void => {
if (typeof window === "undefined") return;
const bridge = (window as Window & { readonly executor?: CrashReportingBridge }).executor;
Expand All @@ -46,6 +65,12 @@ export const initDesktopCrashReporting = (): void => {
},
},
});
initializedReporter = createSentryFrontendErrorReporter((error, applyScope) => {
Sentry.captureException(error, (scope) => {
applyScope(scope);
return scope;
});
});
} catch {
// Reporting failures stay silent — there is nowhere left to report them.
}
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ExecutorPluginsProvider } from "@executor-js/sdk/client";
import { Toaster } from "@executor-js/react/components/sonner";
import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer";
import { plugins as clientPlugins } from "virtual:executor/plugins-client";
import { reportRendererHandledError } from "../crash-reporting";
import { Shell } from "../web/shell";

// The MCP-Apps shell is browser-only — it imports `@tailwindcss/browser`, which
Expand Down Expand Up @@ -41,7 +42,7 @@ function NotFoundPage() {

function RootComponent() {
return (
<ExecutorProvider>
<ExecutorProvider onHandledError={reportRendererHandledError}>
<ExecutorPluginsProvider plugins={clientPlugins}>
<ArtifactRendererProvider loader={artifactRendererLoader}>
<LocalAuthGate>
Expand Down
8 changes: 8 additions & 0 deletions packages/app/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
passWithNoTests: true,
},
});
Loading
Loading