Skip to content

Commit d54b03a

Browse files
committed
Refactor scripts/sentry client init to use store actions instead of directly acting on observables, add unit test
1 parent 98d8e11 commit d54b03a

6 files changed

Lines changed: 161 additions & 50 deletions

File tree

_TODO.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# TODO
22

3+
## Sentry feedback, chat bot tying into my phone and email
4+
5+
See note in src/components/scripts/sentry/client.ts - "User Feedback - allow users to report issues"
6+
37
## Centralize toast notifications to Toast component
48

59
- Right now, the consent/preferences component has its own showNotification() method and toast handling. We have a centralized Toast component but is only being used for network availability right now. Maybe refactor to use a single Toast component, and move the network availability logic somewhere else like bootstrap.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { beforeSendHandler, updateConsentContext } from '../helpers'
3+
4+
const mockScope = {
5+
setContext: vi.fn(),
6+
}
7+
8+
const isDevMock = vi.hoisted(() => vi.fn(() => false))
9+
const getConsentSnapshotMock = vi.hoisted(() => vi.fn(() => ({
10+
analytics: true,
11+
})))
12+
13+
vi.mock('@components/scripts/utils/environmentClient', () => ({
14+
isDev: isDevMock,
15+
}))
16+
17+
vi.mock('@components/scripts/store/consent', () => ({
18+
getConsentSnapshot: getConsentSnapshotMock,
19+
}))
20+
21+
vi.mock('@sentry/browser', () => ({
22+
getCurrentScope: () => mockScope,
23+
}))
24+
25+
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
26+
27+
const createEvent = (): Parameters<typeof beforeSendHandler>[0] => ({
28+
user: { 'ip_address': '127.0.0.1' },
29+
request: { headers: { 'user-agent': 'test' } },
30+
breadcrumbs: [{ message: 'clicked' }],
31+
}) as Parameters<typeof beforeSendHandler>[0]
32+
33+
describe('sentry helpers', () => {
34+
beforeEach(() => {
35+
vi.clearAllMocks()
36+
isDevMock.mockReturnValue(false)
37+
getConsentSnapshotMock.mockReturnValue({ analytics: true })
38+
consoleLogSpy.mockClear()
39+
})
40+
41+
describe('beforeSendHandler', () => {
42+
it('skips sending events in development', () => {
43+
isDevMock.mockReturnValue(true)
44+
const event = createEvent()
45+
46+
const result = beforeSendHandler(event)
47+
48+
expect(result).toBeNull()
49+
expect(getConsentSnapshotMock).not.toHaveBeenCalled()
50+
})
51+
52+
it('returns event unchanged when analytics consent exists', () => {
53+
getConsentSnapshotMock.mockReturnValue({ analytics: true })
54+
55+
const event = createEvent()
56+
const result = beforeSendHandler(event)
57+
58+
expect(result).toBe(event)
59+
expect(event.user?.ip_address).toBe('127.0.0.1')
60+
expect(event.request?.headers).toEqual({ 'user-agent': 'test' })
61+
expect(event.breadcrumbs).toHaveLength(1)
62+
})
63+
64+
it('scrubs PII when analytics consent is missing', () => {
65+
getConsentSnapshotMock.mockReturnValue({ analytics: false })
66+
67+
const event = createEvent()
68+
const result = beforeSendHandler(event)
69+
70+
expect(result).toBe(event)
71+
expect(event.user?.ip_address).toBeUndefined()
72+
expect(event.request?.headers).toBeUndefined()
73+
expect(event.breadcrumbs).toHaveLength(0)
74+
})
75+
})
76+
77+
describe('updateConsentContext', () => {
78+
it('sets consent context and logs status', () => {
79+
updateConsentContext(true)
80+
81+
expect(mockScope.setContext).toHaveBeenCalledWith('consent', {
82+
analytics: true,
83+
timestamp: expect.any(String),
84+
})
85+
expect(consoleLogSpy).toHaveBeenCalledWith(
86+
'🔒 Sentry PII enabled based on analytics consent'
87+
)
88+
})
89+
})
90+
})

src/components/scripts/sentry/client.ts

Lines changed: 5 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ import {
1111
linkedErrorsIntegration,
1212
} from '@sentry/browser'
1313
import { SENTRY_DSN } from 'astro:env/client'
14-
import { isDev, getPackageRelease } from '@components/scripts/utils/environmentClient'
15-
import { $consent } from '@components/scripts/store/consent'
14+
import { getPackageRelease } from '@components/scripts/utils/environmentClient'
15+
import { getAnalyticsConsentPreference } from '@components/scripts/store/consent'
16+
import { beforeSendHandler } from '@components/scripts/sentry/helpers'
1617

1718
/**
1819
* Client-side Sentry initialization with GDPR-compliant PII handling
@@ -33,8 +34,7 @@ import { $consent } from '@components/scripts/store/consent'
3334
export class SentryBootstrap {
3435
static init(): void {
3536
// Check analytics consent for PII handling
36-
const consentState = $consent.get()
37-
const hasAnalyticsConsent = consentState.analytics
37+
const hasAnalyticsConsent = getAnalyticsConsentPreference()
3838

3939
const client = new BrowserClient({
4040
dsn: SENTRY_DSN,
@@ -103,33 +103,7 @@ export class SentryBootstrap {
103103
* Before sending events, you can modify or drop them
104104
* Useful for filtering sensitive data based on consent
105105
*/
106-
beforeSend(event, _hint) {
107-
// Don't send errors in development
108-
if (isDev()) {
109-
return null
110-
}
111-
112-
// If no analytics consent, scrub PII from event
113-
const currentConsent = $consent.get()
114-
if (!currentConsent.analytics) {
115-
// Remove IP address
116-
if (event.user) {
117-
delete event.user.ip_address
118-
}
119-
120-
// Remove user agent and other request data
121-
if (event.request) {
122-
delete event.request.headers
123-
}
124-
125-
// Clear breadcrumbs that may contain user interactions
126-
if (event.breadcrumbs) {
127-
event.breadcrumbs = []
128-
}
129-
}
130-
131-
return event
132-
},
106+
beforeSend: beforeSendHandler,
133107
})
134108

135109
getCurrentScope().setClient(client)
@@ -138,20 +112,4 @@ export class SentryBootstrap {
138112
console.log('✅ Sentry monitoring initialized')
139113
}
140114

141-
/**
142-
* Update Sentry context when consent changes
143-
* Called by consent store subscriber
144-
*/
145-
static updateConsentContext(hasAnalyticsConsent: boolean): void {
146-
const scope = getCurrentScope()
147-
148-
// Set consent status in Sentry context
149-
scope.setContext('consent', {
150-
analytics: hasAnalyticsConsent,
151-
timestamp: new Date().toISOString(),
152-
})
153-
154-
// Log consent change
155-
console.log(`🔒 Sentry PII ${hasAnalyticsConsent ? 'enabled' : 'disabled'} based on analytics consent`)
156-
}
157115
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { getCurrentScope } from '@sentry/browser'
2+
import type { Event as SentryEvent } from '@sentry/types'
3+
import { isDev } from '@components/scripts/utils/environmentClient'
4+
import { getConsentSnapshot } from '@components/scripts/store/consent'
5+
6+
/**
7+
* Applies consent-aware filtering to Sentry events before they are sent.
8+
*/
9+
export function beforeSendHandler(event: SentryEvent): SentryEvent | null {
10+
if (isDev()) {
11+
return null
12+
}
13+
14+
const currentConsent = getConsentSnapshot()
15+
if (!currentConsent.analytics) {
16+
if (event.user) {
17+
delete event.user.ip_address
18+
}
19+
20+
if (event.request) {
21+
delete event.request.headers
22+
}
23+
24+
if (event.breadcrumbs) {
25+
event.breadcrumbs = []
26+
}
27+
}
28+
29+
return event
30+
}
31+
32+
/**
33+
* Sets Sentry scope context when consent changes to keep telemetry aligned with user preferences.
34+
*/
35+
export function updateConsentContext(hasAnalyticsConsent: boolean): void {
36+
const scope = getCurrentScope()
37+
38+
scope.setContext('consent', {
39+
analytics: hasAnalyticsConsent,
40+
timestamp: new Date().toISOString(),
41+
})
42+
43+
console.log(`🔒 Sentry PII ${hasAnalyticsConsent ? 'enabled' : 'disabled'} based on analytics consent`)
44+
}

src/components/scripts/store/__tests__/consent.spec.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// @vitest-environment happy-dom
1+
// @vitest-environment jsdom
22
/**
33
* Unit tests for cookie consent state management
44
*/
@@ -19,6 +19,7 @@ import {
1919
getConsentCookie,
2020
setConsentCookie,
2121
removeConsentCookies,
22+
getAnalyticsConsentPreference,
2223
} from '@components/scripts/store/consent'
2324

2425
// Mock js-cookie
@@ -211,6 +212,16 @@ describe('Cookie Consent Management', () => {
211212
})
212213
})
213214

215+
describe('Analytics Consent Helpers', () => {
216+
it('reports the current analytics consent preference', () => {
217+
expect(getAnalyticsConsentPreference()).toBe(false)
218+
219+
updateConsent('analytics', true)
220+
221+
expect(getAnalyticsConsentPreference()).toBe(true)
222+
})
223+
})
224+
214225
describe('Consent Cookie Helpers', () => {
215226
it('initializes consent cookies when analytics cookie is missing via initConsentCookies', () => {
216227
vi.mocked(getCookie).mockReturnValue(undefined)

src/components/scripts/store/consent.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,10 @@ export function getFunctionalConsentPreference(): boolean {
158158
return $hasFunctionalConsent.get()
159159
}
160160

161+
export function getAnalyticsConsentPreference(): boolean {
162+
return $hasAnalyticsConsent.get()
163+
}
164+
161165
/**
162166
* Subscribe to functional consent changes with immediate synchronization
163167
*/
@@ -347,8 +351,8 @@ export function initConsentSideEffects(): void {
347351
$hasAnalyticsConsent.subscribe((hasConsent) => {
348352
try {
349353
// Dynamically import to avoid circular dependencies and allow lazy loading
350-
import('@components/scripts/sentry/client').then(({ SentryBootstrap }) => {
351-
SentryBootstrap.updateConsentContext(hasConsent)
354+
import('@components/scripts/sentry/helpers').then(({ updateConsentContext }) => {
355+
updateConsentContext(hasConsent)
352356
}).catch((error) => {
353357
// Sentry may not be initialized in all environments
354358
console.warn('Failed to update Sentry consent context:', error)

0 commit comments

Comments
 (0)