Skip to content

Commit 451e212

Browse files
feat(sentry): strip IP addresses and add batch/position context (#1220)
* feat(sentry): strip IP addresses and add batch/position context (#1213) Strip participant IP addresses from Sentry events to prevent re-identification, and add batchName, treatmentFile, and player position as global Sentry tags for easier debugging. - Add beforeSend hook (stripIpAddress) to remove event.user.ip_address - Set batchName + treatmentFile tags when batch config loads (App.jsx) - Set position tag when player is assigned to a game (Game.jsx) - Add vitest unit tests for the beforeSend function - Enhance Playwright Sentry mock to capture setTag calls - Add ERR-TAG Playwright test verifying tag capture infrastructure Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use destructuring to fully omit ip_address key from Sentry events Addresses Copilot review — destructuring removes the key entirely rather than setting it to undefined, preventing any serialization leak. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5db978d commit 451e212

8 files changed

Lines changed: 128 additions & 10 deletions

File tree

client/src/App.jsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* eslint-disable react/jsx-no-bind */
22
import React, { useEffect } from "react";
3+
import * as Sentry from "@sentry/react";
34
import "virtual:windi.css";
45
import "./baseStyles.css";
56

@@ -43,6 +44,8 @@ function InnerParticipant() {
4344
useEffect(() => {
4445
const batchConfig = globals?.get("recruitingBatchConfig");
4546
window.dlBatchName = batchConfig?.batchName;
47+
Sentry.setTag("batchName", batchConfig?.batchName || "unknown");
48+
Sentry.setTag("treatmentFile", batchConfig?.treatmentFile || "unknown");
4649
}, [globals]);
4750

4851
if (!globals) return <Loading />;

client/src/Game.jsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,16 @@ export function Game() {
5858
playerRef.current = player;
5959

6060
const assigned = player?.get("assigned");
61+
const position = player?.get("position");
6162
const gameReady = !!(game && stage && round);
6263

64+
// Attach player position as Sentry tag when assigned to a game
65+
useEffect(() => {
66+
if (assigned && position != null) {
67+
Sentry.setTag("position", String(position));
68+
}
69+
}, [assigned, position]);
70+
6371
// Detect stale state: player is assigned but game hooks haven't populated.
6472
// This can happen if the websocket misses a stage/round update from the server.
6573
// Report to Sentry and reload once to recover.

client/src/index.jsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,12 @@ import App from "./App";
66
import { Button } from "./components/Button";
77
import "./index.css";
88
import { BrowserConditionalRender } from "./components/ConditionalRender";
9+
import { stripIpAddress } from "./utils/sentryBeforeSend";
910

1011
Sentry.init({
1112
dsn: "https://bbe62f66328d40c6bf9008b293e44d7d@o1288526.ingest.sentry.io/6505477",
1213
integrations: [new BrowserTracing()],
13-
// beforeSend(event, hint) {
14-
// Sentry.showReportDialog({
15-
// eventId: event.event_id
16-
// });
17-
// },
14+
beforeSend: stripIpAddress,
1815
attachStacktrace: true,
1916
release: process.env.BUNDLE_DATE,
2017

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Sentry beforeSend hook that strips IP addresses from events
3+
* to prevent re-identification of participants.
4+
*/
5+
export function stripIpAddress(event) {
6+
if (event.user) {
7+
const { ip_address: _stripped, ...userWithoutIp } = event.user;
8+
return { ...event, user: userWithoutIp };
9+
}
10+
return event;
11+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, it, expect } from "vitest";
2+
import { stripIpAddress } from "./sentryBeforeSend";
3+
4+
describe("stripIpAddress", () => {
5+
it("removes ip_address from event.user", () => {
6+
const event = {
7+
message: "test",
8+
user: { id: "abc", ip_address: "1.2.3.4" },
9+
};
10+
const result = stripIpAddress(event);
11+
expect(result.user).not.toHaveProperty("ip_address");
12+
expect(result.user.id).toBe("abc");
13+
});
14+
15+
it("returns the event unchanged when there is no user", () => {
16+
const event = { message: "test" };
17+
const result = stripIpAddress(event);
18+
expect(result).toBe(event);
19+
});
20+
21+
it("handles user object with no ip_address", () => {
22+
const event = { message: "test", user: { id: "abc" } };
23+
const result = stripIpAddress(event);
24+
expect(result.user.ip_address).toBeUndefined();
25+
expect(result.user.id).toBe("abc");
26+
});
27+
28+
it("does not mutate the original event", () => {
29+
const event = {
30+
message: "test",
31+
user: { id: "abc", ip_address: "1.2.3.4" },
32+
};
33+
stripIpAddress(event);
34+
expect(event.user.ip_address).toBe("1.2.3.4");
35+
});
36+
37+
it("preserves other event properties", () => {
38+
const event = {
39+
message: "test",
40+
tags: { batchName: "batch1" },
41+
user: { id: "abc", ip_address: "1.2.3.4", username: "player1" },
42+
};
43+
const result = stripIpAddress(event);
44+
expect(result.message).toBe("test");
45+
expect(result.tags).toEqual({ batchName: "batch1" });
46+
expect(result.user.username).toBe("player1");
47+
});
48+
49+
it("handles null user gracefully", () => {
50+
const event = { message: "test", user: null };
51+
const result = stripIpAddress(event);
52+
expect(result).toBe(event);
53+
});
54+
});

playwright/component-tests/video-call/mocked/ErrorReporting.ct.jsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react';
22
import { test, expect } from '@playwright/experimental-ct-react';
33
import { VideoCall } from '../../../../client/src/call/VideoCall';
4+
import { SentryTagTestComponent } from '../../../test-components/SentryTagTestComponent';
45

56
/**
67
* Component Tests for A/V Error Reporting to Sentry
@@ -286,3 +287,24 @@ test.describe('A/V Error Reporting (Sentry)', () => {
286287
expect(avMsg.hint.tags.avIssueId).toBeTruthy();
287288
});
288289
});
290+
291+
/**
292+
* ERR-TAG: Sentry.setTag calls are captured by the mock
293+
*
294+
* Verifies the mock infrastructure records setTag calls in
295+
* window.mockSentryCaptures.tags — used by App.jsx (batchName, treatmentFile)
296+
* and Game.jsx (position) to attach context to all Sentry events.
297+
*/
298+
test('ERR-TAG: Sentry.setTag calls are captured in mockSentryCaptures.tags', async ({ mount, page }) => {
299+
const component = await mount(
300+
<SentryTagTestComponent
301+
tags={{ batchName: 'test-batch', treatmentFile: 'projects/example/test.yaml', position: '2' }}
302+
/>
303+
);
304+
await expect(page.locator('[data-test="tagTestMounted"]')).toBeVisible({ timeout: 5000 });
305+
306+
const tags = await page.evaluate(() => window.mockSentryCaptures.tags);
307+
expect(tags.batchName).toBe('test-batch');
308+
expect(tags.treatmentFile).toBe('projects/example/test.yaml');
309+
expect(tags.position).toBe('2');
310+
});

playwright/mocks/sentry/mock.js

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
* exceptions: [{ error, hint, timestamp }],
2323
* breadcrumbs: [{ category, message, level, data, timestamp }],
2424
* events: [{ event, hint, timestamp }],
25+
* tags: { key: value, ... },
2526
* reset() { ... }
2627
* }
2728
*/
@@ -32,11 +33,13 @@ function initCaptures() {
3233
exceptions: [],
3334
breadcrumbs: [],
3435
events: [],
36+
tags: {},
3537
reset() {
3638
this.messages = [];
3739
this.exceptions = [];
3840
this.breadcrumbs = [];
3941
this.events = [];
42+
this.tags = {};
4043
},
4144
};
4245
if (typeof window !== 'undefined') {
@@ -69,19 +72,23 @@ export function addBreadcrumb(breadcrumb) {
6972
captures.breadcrumbs.push({ ...breadcrumb, timestamp: Date.now() });
7073
}
7174

72-
// Context setters (no observable side effects needed for tests)
75+
// Context setters
7376
export function setUser() {}
74-
export function setTag() {}
75-
export function setTags() {}
77+
export function setTag(key, value) {
78+
captures.tags[key] = value;
79+
}
80+
export function setTags(tags) {
81+
Object.assign(captures.tags, tags);
82+
}
7683
export function setExtra() {}
7784
export function setExtras() {}
7885
export function setContext() {}
7986

8087
// Scope management - pass a mock scope that also forwards breadcrumbs to capture store
8188
const mockScope = {
8289
setUser: () => {},
83-
setTag: () => {},
84-
setTags: () => {},
90+
setTag: (key, value) => { captures.tags[key] = value; },
91+
setTags: (tags) => { Object.assign(captures.tags, tags); },
8592
setExtra: () => {},
8693
setExtras: () => {},
8794
setContext: () => {},
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import React, { useEffect } from 'react';
2+
import * as Sentry from '@sentry/react';
3+
4+
/**
5+
* Minimal component that calls Sentry.setTag for each provided tag.
6+
* Used by ErrorReporting.ct.jsx to verify the Sentry mock captures setTag calls.
7+
*/
8+
export function SentryTagTestComponent({ tags = {} }) {
9+
useEffect(() => {
10+
Object.entries(tags).forEach(([key, value]) => {
11+
Sentry.setTag(key, value);
12+
});
13+
}, [tags]);
14+
15+
return <div data-test="tagTestMounted">Tag test</div>;
16+
}

0 commit comments

Comments
 (0)