Skip to content

Commit 12fd118

Browse files
committed
address PR review polish: inject auth telemetry hooks + test wiring
- fetch-with-refresh: drop the direct Sentry import; take onRefreshOutcome / onLogout hooks instead (decouples the generic module from the Sentry singleton). client.ts wires them to addAuthBreadcrumb / captureLogout. - Add web/test/fetch-with-refresh.test.ts asserting the logout wiring: one onLogout per terminal exit (refresh_rejected / retry_401), none on the happy or non-401 paths — locks the behavior the feature exists to deliver. - Only register browserTracingIntegration when tracesSampleRate > 0, so an enabled-but-unsampled tenant doesn't instrument fetch / append trace headers. - Soften the beforeBreadcrumb comment: opaque ids in URL *paths* are within the trust boundary (tagged anyway); only query strings are stripped.
1 parent e55ca5e commit 12fd118

4 files changed

Lines changed: 128 additions & 15 deletions

File tree

web/src/api/client.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type {
99
PlacementEntry,
1010
ToolCallResult,
1111
} from "../types";
12-
import { setSentryWorkspace } from "../sentry";
12+
import { addAuthBreadcrumb, captureLogout, setSentryWorkspace } from "../sentry";
1313
import { createFetchWithRefresh } from "./fetch-with-refresh";
1414

1515
// ---------------------------------------------------------------------------
@@ -167,6 +167,10 @@ const refreshInterceptor = createFetchWithRefresh({
167167
fetch: ((input, init) => globalThis.fetch(input, init)) as typeof fetch,
168168
refreshUrl: `${API_BASE}/v1/auth/refresh`,
169169
onAuthError: () => onAuthError?.(),
170+
// Telemetry: breadcrumb every refresh outcome; one event per involuntary
171+
// logout (captureLogout dedupes the concurrent-401 calls into one incident).
172+
onRefreshOutcome: (outcome) => addAuthBreadcrumb(`refresh:${outcome}`),
173+
onLogout: captureLogout,
170174
});
171175

172176
const fetchWithRefresh = refreshInterceptor;

web/src/api/fetch-with-refresh.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,6 @@
3131
* blips without surfacing an error at all.
3232
*/
3333

34-
import { addAuthBreadcrumb, captureLogout } from "../sentry";
35-
3634
/** How many times to re-attempt a refresh that failed at the transport layer. */
3735
const REFRESH_NETWORK_RETRIES = 2;
3836
/** Delay between transport-failure retries. */
@@ -61,6 +59,18 @@ export interface FetchWithRefreshOptions {
6159
* refresh. NOT called on a transient transport failure.
6260
*/
6361
onAuthError?: () => void;
62+
/**
63+
* Telemetry hook fired with each refresh outcome (`refreshed` / `rejected` /
64+
* `unavailable`). Injected so this module stays decoupled from the Sentry
65+
* singleton; the client wires it to a breadcrumb. Optional — defaults to no-op.
66+
*/
67+
onRefreshOutcome?: (outcome: RefreshOutcome) => void;
68+
/**
69+
* Telemetry hook fired on the two terminal involuntary-logout exits, with a
70+
* reason (`refresh_rejected` / `retry_401`). Injected for the same reason as
71+
* `onRefreshOutcome`. Optional — defaults to no-op.
72+
*/
73+
onLogout?: (reason: "refresh_rejected" | "retry_401") => void;
6474
}
6575

6676
export interface FetchWithRefresh {
@@ -76,7 +86,7 @@ export interface FetchWithRefresh {
7686
}
7787

7888
export function createFetchWithRefresh(options: FetchWithRefreshOptions): FetchWithRefresh {
79-
const { fetch: fetchFn, refreshUrl, onAuthError } = options;
89+
const { fetch: fetchFn, refreshUrl, onAuthError, onRefreshOutcome, onLogout } = options;
8090

8191
let refreshInFlight: Promise<RefreshOutcome> | null = null;
8292

@@ -137,14 +147,14 @@ export function createFetchWithRefresh(options: FetchWithRefreshOptions): FetchW
137147

138148
// 401 — attempt silent refresh
139149
const outcome = await refresh();
140-
// Breadcrumb every outcome (not an event) so the trail preceding a logout is
150+
// Report every outcome (not an event) so the trail preceding a logout is
141151
// visible without quota burn — transient blips vs. a genuine rejection.
142-
addAuthBreadcrumb(`refresh:${outcome}`);
152+
onRefreshOutcome?.(outcome);
143153

144154
if (outcome === "rejected") {
145-
// The refresh token was refused — the session is genuinely over. Emit the
146-
// single involuntary-logout event carrying the breadcrumb trail above.
147-
captureLogout("refresh_rejected");
155+
// The refresh token was refused — the session is genuinely over. Signal the
156+
// involuntary logout (the client emits a single event for the incident).
157+
onLogout?.("refresh_rejected");
148158
onAuthError?.();
149159
return res;
150160
}
@@ -160,7 +170,7 @@ export function createFetchWithRefresh(options: FetchWithRefreshOptions): FetchW
160170
const retry = await fetchFn(input, init);
161171
if (retry.status === 401) {
162172
// A fresh token still gets 401 → genuinely unauthenticated.
163-
captureLogout("retry_401");
173+
onLogout?.("retry_401");
164174
onAuthError?.();
165175
}
166176
return retry;

web/src/sentry.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,12 @@ export function initSentry(): void {
5252
environment: cfg.environment,
5353
release: cfg.release,
5454
sendDefaultPii: false,
55-
integrations: [Sentry.browserTracingIntegration()],
56-
// No Session Replay: replayIntegration records the DOM (prompts, tool
57-
// results, file contents) — the exact content the trust rule forbids. Same
58-
// intent as telemetry.ts disabling PostHog session recording.
55+
// Performance tracing only when a tenant opts in (sample rate > 0) — avoids
56+
// instrumenting fetch and appending sentry-trace/baggage headers for nothing.
57+
// No Session Replay either: replayIntegration records the DOM (prompts, tool
58+
// results, file contents) — the content the trust rule forbids (cf.
59+
// telemetry.ts disabling PostHog session recording).
60+
integrations: (s.tracesSampleRate ?? 0) > 0 ? [Sentry.browserTracingIntegration()] : [],
5961
tracesSampleRate: s.tracesSampleRate ?? 0,
6062
// tracePropagationTargets is left at the SDK default (same-origin), which is
6163
// exactly our case — all API calls are same-origin /v1/* proxied to the
@@ -97,7 +99,9 @@ export function beforeSend(event: Sentry.ErrorEvent): Sentry.ErrorEvent {
9799
export function beforeBreadcrumb(crumb: Sentry.Breadcrumb): Sentry.Breadcrumb | null {
98100
// App logs can carry prompts / PII — never breadcrumb them.
99101
if (crumb.category === "console") return null;
100-
// A workspace or conversation id must not ride along in a fetch/xhr/nav URL.
102+
// Strip query strings from fetch/xhr/nav URLs. (Opaque ids in the URL *path*
103+
// — e.g. /w/<wsId> — are within the trust boundary: they're tagged anyway and
104+
// aren't the content the rule forbids, so we don't rewrite path segments.)
101105
const url = crumb.data?.url;
102106
if (typeof url === "string") {
103107
const q = url.indexOf("?");
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// ---------------------------------------------------------------------------
2+
// createFetchWithRefresh — telemetry wiring for the involuntary-logout signal.
3+
//
4+
// The whole point of this PR is to make "random logouts" observable. The auth
5+
// telemetry is injected as hooks (onRefreshOutcome / onLogout), so these tests
6+
// assert the exact wiring — one logout signal on each terminal exit, none on the
7+
// happy path — locking the behavior the feature exists to deliver.
8+
// ---------------------------------------------------------------------------
9+
10+
import { describe, expect, mock, test } from "bun:test";
11+
import { createFetchWithRefresh } from "../src/api/fetch-with-refresh";
12+
13+
const REFRESH = "/v1/auth/refresh";
14+
15+
/** Fake fetch: refresh URL returns `refreshStatus`; the original request walks
16+
* `reqStatuses` (first call, then the post-refresh retry). */
17+
function makeFetch(reqStatuses: number[], refreshStatus: number) {
18+
let i = 0;
19+
const fn = mock((input: string) => {
20+
if (input === REFRESH) return Promise.resolve(new Response(null, { status: refreshStatus }));
21+
const status = reqStatuses[Math.min(i, reqStatuses.length - 1)] ?? 200;
22+
i++;
23+
return Promise.resolve(new Response(null, { status }));
24+
});
25+
return fn as unknown as typeof globalThis.fetch;
26+
}
27+
28+
describe("createFetchWithRefresh telemetry", () => {
29+
test("rejected refresh → onLogout('refresh_rejected') once + onAuthError", async () => {
30+
const onLogout = mock(() => {});
31+
const onAuthError = mock(() => {});
32+
const onRefreshOutcome = mock(() => {});
33+
const f = createFetchWithRefresh({
34+
fetch: makeFetch([401], 401),
35+
refreshUrl: REFRESH,
36+
onAuthError,
37+
onLogout,
38+
onRefreshOutcome,
39+
});
40+
const res = await f("/v1/x");
41+
expect(res.status).toBe(401);
42+
expect(onRefreshOutcome).toHaveBeenCalledWith("rejected");
43+
expect(onLogout).toHaveBeenCalledTimes(1);
44+
expect(onLogout).toHaveBeenCalledWith("refresh_rejected");
45+
expect(onAuthError).toHaveBeenCalledTimes(1);
46+
});
47+
48+
test("refresh ok + retry ok → no logout", async () => {
49+
const onLogout = mock(() => {});
50+
const onAuthError = mock(() => {});
51+
const onRefreshOutcome = mock(() => {});
52+
const f = createFetchWithRefresh({
53+
fetch: makeFetch([401, 200], 200),
54+
refreshUrl: REFRESH,
55+
onAuthError,
56+
onLogout,
57+
onRefreshOutcome,
58+
});
59+
const res = await f("/v1/x");
60+
expect(res.status).toBe(200);
61+
expect(onRefreshOutcome).toHaveBeenCalledWith("refreshed");
62+
expect(onLogout).not.toHaveBeenCalled();
63+
expect(onAuthError).not.toHaveBeenCalled();
64+
});
65+
66+
test("refresh ok but retry still 401 → onLogout('retry_401')", async () => {
67+
const onLogout = mock(() => {});
68+
const onAuthError = mock(() => {});
69+
const f = createFetchWithRefresh({
70+
fetch: makeFetch([401, 401], 200),
71+
refreshUrl: REFRESH,
72+
onAuthError,
73+
onLogout,
74+
});
75+
await f("/v1/x");
76+
expect(onLogout).toHaveBeenCalledTimes(1);
77+
expect(onLogout).toHaveBeenCalledWith("retry_401");
78+
expect(onAuthError).toHaveBeenCalledTimes(1);
79+
});
80+
81+
test("non-401 response: no refresh, no telemetry", async () => {
82+
const onLogout = mock(() => {});
83+
const onRefreshOutcome = mock(() => {});
84+
const f = createFetchWithRefresh({
85+
fetch: makeFetch([200], 200),
86+
refreshUrl: REFRESH,
87+
onLogout,
88+
onRefreshOutcome,
89+
});
90+
const res = await f("/v1/x");
91+
expect(res.status).toBe(200);
92+
expect(onRefreshOutcome).not.toHaveBeenCalled();
93+
expect(onLogout).not.toHaveBeenCalled();
94+
});
95+
});

0 commit comments

Comments
 (0)