mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-16 05:17:41 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators can enable Sentry for the server and the signed-in browser. > - The server SDK reads `SENTRY_ENVIRONMENT` from the process environment. > - The browser receives its DSN through the session response, but receives no environment. > - A browser in staging therefore reports errors under the SDK's production default. > - This pull request passes the configured environment through the existing session and monitoring gate. > - Browser errors then identify the deployment environment while preserving the existing privacy settings. ## Linked Issues or Issue Description **What happened?** With `SENTRY_ENVIRONMENT=staging`, browser exceptions are tagged `production`. This can send errors to the wrong environment's alerts and makes deployment follow-up unreliable. **Expected behavior** The browser uses the server's configured Sentry environment. A reused image works in either staging or production. A signed-out browser still sends no events. **Steps to reproduce** 1. Configure a frontend Sentry DSN and `SENTRY_ENVIRONMENT=staging`. 2. Sign in and capture a browser exception. 3. Inspect the event environment. Before this change, it is `production`. **Paperclip version or commit** Reproduced on `a3749aac4680a901fa0fe1cc898907887abc9908` with the real browser SDK and a local test transport. **Deployment mode** Authenticated server and browser with optional Sentry monitoring enabled. No duplicate environment-attribution issue or pull request was found in the targeted GitHub search. ## What Changed - Add `sentryEnvironment` to the authenticated session response and shared schema. The optional field supports a newer browser reading an older server response. - Pass the environment to the browser SDK. An environment change restarts the client through its existing serialized lifecycle. - Cover environment attribution with a real SDK event, session authorization, unchanged-session refetches, environment changes, and legacy responses. - Document configuration and compatibility. Keep the loaded bundle's release identity and existing privacy filters. ## Verification - The regression test emits `production` for a requested staging environment before the fix. - Focused route, schema, browser lifecycle and real-SDK tests: 69 pass. - UI and shared-package typechecks, direct server `tsc --noEmit`, and token gates pass. - Full `pnpm build` and `pnpm -r typecheck` were attempted. Both stop at the Runner Rust step because `cargo` is absent on this machine. - Complete UI suite: 6,540 tests pass in 626 files. - Full `pnpm test:run`: 8,210 passed, 14 failed, 4,753 skipped; 36 files fail due to embedded PostgreSQL startup/cleanup and macOS runtime-cache `EACCES`. These match the existing local baseline; none touch the changed behavior. - Greptile: 5/5, no unresolved review threads. Linux CI has passed Build, Typecheck + Release Registry, and the completed test jobs so far. Remaining jobs are running or queued: the AWS runner provisioner is retrying EC2 CreateFleet `InternalError` responses. Full results will be recorded before merge. ## Risks Low risk. This adds one optional session field and changes Sentry attribution only. No migration or new monitoring opt-in is introduced. Missing settings keep the browser SDK default. Agent and unauthenticated requests still receive 401 without monitoring settings. Existing loaded browser bundles keep their old behavior until refreshed. ## Model Used OpenAI GPT-6 via Codex, with reasoning, repository inspection, code editing, and test execution. The session does not expose an exact model snapshot or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass (focused and full UI suites pass; full-root environment failures documented above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
191 lines
6.9 KiB
TypeScript
191 lines
6.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import { SentryGate } from "./SentryGate";
|
|
|
|
const getSessionMock = vi.hoisted(() => vi.fn());
|
|
const initBrowserErrorMonitoringMock = vi.hoisted(() => vi.fn(async (_dsn: string, _environment?: string) => {}));
|
|
const teardownBrowserErrorMonitoringMock = vi.hoisted(() => vi.fn(async () => {}));
|
|
|
|
vi.mock("@/api/auth", () => ({
|
|
authApi: { getSession: () => getSessionMock() },
|
|
}));
|
|
|
|
vi.mock("@/lib/sentry", () => ({
|
|
initBrowserErrorMonitoring: (dsn: string, environment?: string) => initBrowserErrorMonitoringMock(dsn, environment),
|
|
teardownBrowserErrorMonitoring: () => teardownBrowserErrorMonitoringMock(),
|
|
}));
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
async function flushReact() {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
|
|
describe("SentryGate", () => {
|
|
let container: HTMLDivElement;
|
|
let queryClient: QueryClient;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
});
|
|
|
|
afterEach(() => {
|
|
container.remove();
|
|
document.body.innerHTML = "";
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
async function renderGate() {
|
|
const root = createRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<SentryGate />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
return root;
|
|
}
|
|
|
|
it("loads no SDK when the session query answers null", async () => {
|
|
getSessionMock.mockResolvedValue(null);
|
|
|
|
const root = await renderGate();
|
|
|
|
expect(initBrowserErrorMonitoringMock).not.toHaveBeenCalled();
|
|
expect(container.textContent).toBe("");
|
|
root.unmount();
|
|
});
|
|
|
|
it("loads no SDK when the session sentryDsn is null", async () => {
|
|
getSessionMock.mockResolvedValue({
|
|
session: { id: "s1", userId: "u1" },
|
|
user: { id: "u1", email: "a@b.com", name: "Jane", image: null },
|
|
sentryDsn: null,
|
|
});
|
|
|
|
const root = await renderGate();
|
|
|
|
expect(initBrowserErrorMonitoringMock).not.toHaveBeenCalled();
|
|
root.unmount();
|
|
});
|
|
|
|
it("opens the gate once when the session carries a DSN", async () => {
|
|
getSessionMock.mockResolvedValue({
|
|
session: { id: "s1", userId: "u1" },
|
|
user: { id: "u1", email: "a@b.com", name: "Jane", image: null },
|
|
sentryDsn: "https://public@o0.ingest.sentry.io/1",
|
|
});
|
|
|
|
const root = await renderGate();
|
|
|
|
expect(initBrowserErrorMonitoringMock).toHaveBeenCalledTimes(1);
|
|
expect(initBrowserErrorMonitoringMock).toHaveBeenCalledWith("https://public@o0.ingest.sentry.io/1", undefined);
|
|
root.unmount();
|
|
});
|
|
|
|
it("opens the gate after a signed-out session query refetches with a DSN", async () => {
|
|
getSessionMock.mockResolvedValue(null);
|
|
const root = await renderGate();
|
|
expect(initBrowserErrorMonitoringMock).not.toHaveBeenCalled();
|
|
|
|
getSessionMock.mockResolvedValue({
|
|
session: { id: "s1", userId: "u1" },
|
|
user: { id: "u1", email: "a@b.com", name: "Jane", image: null },
|
|
sentryDsn: "https://public@o0.ingest.sentry.io/1",
|
|
});
|
|
await act(async () => {
|
|
await queryClient.refetchQueries({ queryKey: queryKeys.auth.session });
|
|
});
|
|
await flushReact();
|
|
|
|
expect(initBrowserErrorMonitoringMock).toHaveBeenCalledTimes(1);
|
|
expect(initBrowserErrorMonitoringMock).toHaveBeenCalledWith("https://public@o0.ingest.sentry.io/1", undefined);
|
|
root.unmount();
|
|
});
|
|
|
|
it("restarts monitoring when the session environment changes with the same DSN", async () => {
|
|
const session = {
|
|
session: { id: "s1", userId: "u1" },
|
|
user: { id: "u1", email: "a@b.com", name: "Jane", image: null },
|
|
sentryDsn: "https://public@o0.ingest.sentry.io/1",
|
|
sentryEnvironment: "staging",
|
|
};
|
|
getSessionMock.mockResolvedValue(session);
|
|
const root = await renderGate();
|
|
try {
|
|
expect(initBrowserErrorMonitoringMock).toHaveBeenLastCalledWith(session.sentryDsn, "staging");
|
|
await act(async () => {
|
|
await queryClient.refetchQueries({ queryKey: queryKeys.auth.session });
|
|
});
|
|
await flushReact();
|
|
expect(initBrowserErrorMonitoringMock).toHaveBeenCalledTimes(1);
|
|
expect(teardownBrowserErrorMonitoringMock).not.toHaveBeenCalled();
|
|
|
|
getSessionMock.mockResolvedValue({ ...session, sentryEnvironment: "production" });
|
|
await act(async () => {
|
|
await queryClient.refetchQueries({ queryKey: queryKeys.auth.session });
|
|
});
|
|
await flushReact();
|
|
expect(teardownBrowserErrorMonitoringMock).toHaveBeenCalledTimes(1);
|
|
expect(initBrowserErrorMonitoringMock).toHaveBeenCalledTimes(2);
|
|
expect(initBrowserErrorMonitoringMock).toHaveBeenLastCalledWith(session.sentryDsn, "production");
|
|
expect(teardownBrowserErrorMonitoringMock.mock.invocationCallOrder[0])
|
|
.toBeLessThan(initBrowserErrorMonitoringMock.mock.invocationCallOrder[1]);
|
|
} finally {
|
|
root.unmount();
|
|
}
|
|
});
|
|
|
|
it("closes browser monitoring when sign-out clears the session's DSN", async () => {
|
|
getSessionMock.mockResolvedValue({
|
|
session: { id: "s1", userId: "u1" },
|
|
user: { id: "u1", email: "a@b.com", name: "Jane", image: null },
|
|
sentryDsn: "https://public@o0.ingest.sentry.io/1",
|
|
});
|
|
const root = await renderGate();
|
|
expect(initBrowserErrorMonitoringMock).toHaveBeenCalledTimes(1);
|
|
expect(teardownBrowserErrorMonitoringMock).not.toHaveBeenCalled();
|
|
|
|
// `useSignOut` resets the session query on sign-out; a mounted observer
|
|
// (this component) sees its data drop to `undefined` immediately.
|
|
getSessionMock.mockResolvedValue(null);
|
|
await act(async () => {
|
|
queryClient.resetQueries({ queryKey: queryKeys.auth.session });
|
|
});
|
|
await flushReact();
|
|
|
|
expect(teardownBrowserErrorMonitoringMock).toHaveBeenCalledTimes(1);
|
|
// Signing back in must start a fresh client, not skip re-init.
|
|
expect(initBrowserErrorMonitoringMock).toHaveBeenCalledTimes(1);
|
|
root.unmount();
|
|
});
|
|
|
|
it("closes browser monitoring when the gate unmounts while a session DSN is set", async () => {
|
|
getSessionMock.mockResolvedValue({
|
|
session: { id: "s1", userId: "u1" },
|
|
user: { id: "u1", email: "a@b.com", name: "Jane", image: null },
|
|
sentryDsn: "https://public@o0.ingest.sentry.io/1",
|
|
});
|
|
const root = await renderGate();
|
|
expect(initBrowserErrorMonitoringMock).toHaveBeenCalledTimes(1);
|
|
|
|
root.unmount();
|
|
|
|
expect(teardownBrowserErrorMonitoringMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|