diff --git a/src/components/scripts/sentry/__tests__/helpers.spec.ts b/src/components/scripts/sentry/__tests__/helpers.spec.ts index 59d629b6..1d7470a8 100644 --- a/src/components/scripts/sentry/__tests__/helpers.spec.ts +++ b/src/components/scripts/sentry/__tests__/helpers.spec.ts @@ -71,6 +71,23 @@ const createConsentRateLimitHttpErrorEvent = (): Parameters[0] +const createConsentCheckpointHttpErrorEvent = (): Parameters[0] => + ({ + type: 'error', + request: { url: 'https://www.webstackbuilders.com/_actions/gdpr.consentCreate' }, + exception: { + values: [ + { + value: 'HTTP Client Error with status code: 403', + mechanism: { + type: 'auto.http.client.fetch', + handled: false, + }, + }, + ], + }, + }) as unknown as Parameters[0] + const createDownloadsSubmitHttpErrorEvent = (): Parameters[0] => ({ type: 'error', @@ -126,6 +143,28 @@ const createConsentLogRetryErrorEvent = (): Parameters }, }) as unknown as Parameters[0] +const createConsentCheckpointClientErrorEvent = (): Parameters[0] => + ({ + type: 'error', + message: 'Vercel Security Checkpoint', + request: { url: 'https://www.webstackbuilders.com/contact' }, + tags: { + scriptName: 'cookieConsent', + operation: 'logConsentToAPI', + }, + exception: { + values: [ + { + value: 'Vercel Security Checkpoint', + mechanism: { + type: 'generic', + handled: true, + }, + }, + ], + }, + }) as unknown as Parameters[0] + const createHint = (): Parameters[1] => ({}) as Parameters[1] @@ -186,6 +225,17 @@ describe('sentry helpers', () => { expect(result).toBeNull() }) + it('drops handled consent checkpoint http client failures', () => { + isProdMock.mockReturnValue(true) + getConsentSnapshotMock.mockReturnValue({ analytics: true }) + + const event = createConsentCheckpointHttpErrorEvent() + + const result = beforeSendHandler(event, createHint()) + + expect(result).toBeNull() + }) + it('drops handled downloads action http client failures', () => { isProdMock.mockReturnValue(true) getConsentSnapshotMock.mockReturnValue({ analytics: true }) @@ -219,6 +269,17 @@ describe('sentry helpers', () => { expect(result).toBeNull() }) + it('drops handled consent checkpoint client errors', () => { + isProdMock.mockReturnValue(true) + getConsentSnapshotMock.mockReturnValue({ analytics: true }) + + const event = createConsentCheckpointClientErrorEvent() + + const result = beforeSendHandler(event, createHint()) + + expect(result).toBeNull() + }) + it('scrubs PII when analytics consent is missing and preserves safe breadcrumbs', () => { isProdMock.mockReturnValue(true) getConsentSnapshotMock.mockReturnValue({ analytics: false }) diff --git a/src/components/scripts/sentry/helpers.ts b/src/components/scripts/sentry/helpers.ts index d38a0c0f..6abd8efb 100644 --- a/src/components/scripts/sentry/helpers.ts +++ b/src/components/scripts/sentry/helpers.ts @@ -28,18 +28,19 @@ const isHandledContactSubmitHttpError = (event: Parameters[0] ) } -const isHandledConsentRateLimitHttpError = (event: Parameters[0]): boolean => { +const isHandledConsentHttpError = (event: Parameters[0]): boolean => { const requestUrl = event.request?.url const exception = event.exception?.values?.[0] const mechanismType = exception?.mechanism?.type const errorMessage = exception?.value ?? event.message ?? '' + const statusCodeMatch = typeof errorMessage === 'string' ? errorMessage.match(/status code:\s*(\d{3})/i) : null + const statusCode = statusCodeMatch?.[1] ? Number(statusCodeMatch[1]) : undefined return ( typeof requestUrl === 'string' && isConsentActionRequest(requestUrl) && mechanismType === 'auto.http.client.fetch' && - typeof errorMessage === 'string' && - errorMessage.includes('HTTP Client Error with status code: 429') + (statusCode === 403 || statusCode === 429) ) } @@ -87,6 +88,20 @@ const isHandledConsentLogRetryError = (event: Parameters[0]): ) } +const isHandledConsentCheckpointClientError = (event: Parameters[0]): boolean => { + const errorMessage = event.exception?.values?.[0]?.value ?? event.message ?? '' + const tags = event.tags ?? {} + + return ( + tags['scriptName'] === 'cookieConsent' && + tags['operation'] === 'logConsentToAPI' && + typeof errorMessage === 'string' && + (errorMessage.toLowerCase().includes('vercel security checkpoint') || + (errorMessage.toLowerCase().includes('[0]['breadcrumbs']> ) { @@ -117,9 +132,10 @@ export const beforeSendHandler: BeforeSendHandler = (event, _hint) => { return null } - // Consent logging is best-effort on the client. Rate limiting here is expected - // under bursty preference changes, so drop the browser-side auto-fetch event. - if (isHandledConsentRateLimitHttpError(event)) { + // Consent logging is best-effort on the client. Rate limiting and Vercel + // security checkpoints can block the action without any user-visible impact, + // so drop the browser-side auto-fetch event. + if (isHandledConsentHttpError(event)) { return null } @@ -141,6 +157,12 @@ export const beforeSendHandler: BeforeSendHandler = (event, _hint) => { return null } + // If a consent checkpoint response is wrapped into a handled client error, + // drop that duplicate event as the action itself is already filtered. + if (isHandledConsentCheckpointClientError(event)) { + return null + } + const currentConsent = getConsentSnapshot() if (!currentConsent.analytics) { if (event.user) { diff --git a/src/components/scripts/store/__tests__/consent.spec.ts b/src/components/scripts/store/__tests__/consent.spec.ts index a3ca71bc..db868088 100644 --- a/src/components/scripts/store/__tests__/consent.spec.ts +++ b/src/components/scripts/store/__tests__/consent.spec.ts @@ -603,6 +603,109 @@ describe('Consent side effects', () => { expect(handleScriptErrorSpy).not.toHaveBeenCalled() }) + it('retries consent logging after a thrown 429 client error without reporting a script error', async () => { + vi.useFakeTimers() + + consentCreateMock + .mockRejectedValueOnce(new Error('HTTP Client Error with status code: 429')) + .mockResolvedValueOnce({ data: { success: true, record: { id: 'consent-1' } } }) + + const handleScriptErrorSpy = vi.spyOn(errorHandlerModule, 'handleScriptError') + + let consentListener: + | ((_state: ConsentState, _oldState?: ConsentState) => Promise | void) + | undefined + vi.spyOn($consent, 'subscribe').mockImplementation(listener => { + consentListener = listener + return () => {} + }) + vi.spyOn($isConsentBannerVisible, 'subscribe').mockImplementation(() => () => {}) + vi.spyOn($hasFunctionalConsent, 'subscribe').mockImplementation(() => () => {}) + vi.spyOn($hasAnalyticsConsent, 'subscribe').mockImplementation(() => () => {}) + + initConsentSideEffects() + + const oldState = { + analytics: false, + marketing: false, + functional: false, + DataSubjectId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + } + const newState = { + analytics: true, + marketing: false, + functional: false, + DataSubjectId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + } + + await consentListener?.(newState, oldState) + + await vi.advanceTimersByTimeAsync(250) + + await vi.waitFor(() => { + expect(consentCreateMock).toHaveBeenCalledTimes(1) + }) + + expect(handleScriptErrorSpy).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(5_000) + + await vi.waitFor(() => { + expect(consentCreateMock).toHaveBeenCalledTimes(2) + }) + + expect(handleScriptErrorSpy).not.toHaveBeenCalled() + }) + + it('suppresses consent logging when Vercel security checkpoint blocks the action', async () => { + vi.useFakeTimers() + + consentCreateMock.mockRejectedValueOnce( + new Error('Vercel Security Checkpoint') + ) + + const handleScriptErrorSpy = vi.spyOn(errorHandlerModule, 'handleScriptError') + + let consentListener: + | ((_state: ConsentState, _oldState?: ConsentState) => Promise | void) + | undefined + vi.spyOn($consent, 'subscribe').mockImplementation(listener => { + consentListener = listener + return () => {} + }) + vi.spyOn($isConsentBannerVisible, 'subscribe').mockImplementation(() => () => {}) + vi.spyOn($hasFunctionalConsent, 'subscribe').mockImplementation(() => () => {}) + vi.spyOn($hasAnalyticsConsent, 'subscribe').mockImplementation(() => () => {}) + + initConsentSideEffects() + + const oldState = { + analytics: false, + marketing: false, + functional: false, + DataSubjectId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + } + const newState = { + analytics: true, + marketing: false, + functional: false, + DataSubjectId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + } + + await consentListener?.(newState, oldState) + + await vi.advanceTimersByTimeAsync(250) + + await vi.waitFor(() => { + expect(consentCreateMock).toHaveBeenCalledTimes(1) + }) + + await vi.advanceTimersByTimeAsync(30_000) + + expect(consentCreateMock).toHaveBeenCalledTimes(1) + expect(handleScriptErrorSpy).not.toHaveBeenCalled() + }) + it('deletes the data subject id when functional consent is revoked', () => { let functionalListener: ((_hasConsent: boolean) => void) | undefined vi.spyOn($hasFunctionalConsent, 'subscribe').mockImplementation(listener => { diff --git a/src/components/scripts/store/consent.ts b/src/components/scripts/store/consent.ts index 3243b51f..b24ae4cf 100644 --- a/src/components/scripts/store/consent.ts +++ b/src/components/scripts/store/consent.ts @@ -38,6 +38,32 @@ const CONSENT_COOKIE_PREFIX = 'consent_' const CONSENT_LOG_DEBOUNCE_MS = 250 const CONSENT_LOG_MAX_RETRY_DELAY_MS = 30_000 +type ConsentActionError = { + code?: string + message?: string + status?: number + statusText?: string + cause?: unknown +} + +const createConsentActionError = (params: { + code?: string | undefined + message?: string | undefined + status?: number | undefined + statusText?: string | undefined + cause?: unknown +}): ConsentActionError => { + const actionError: ConsentActionError = {} + + if (params.code !== undefined) actionError.code = params.code + if (params.message !== undefined) actionError.message = params.message + if (params.status !== undefined) actionError.status = params.status + if (params.statusText !== undefined) actionError.statusText = params.statusText + if (params.cause !== undefined) actionError.cause = params.cause + + return actionError +} + class ConsentLogRetryableError extends Error { readonly retryAfterMs: number @@ -434,6 +460,95 @@ export function initConsentSideEffects(): void { return 5_000 } + const parseStatusCode = (value: unknown): number | undefined => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + + if (typeof value === 'string') { + const parsedValue = Number(value) + if (Number.isFinite(parsedValue)) { + return parsedValue + } + } + + return undefined + } + + const getErrorRecord = (value: unknown): Record | undefined => { + return typeof value === 'object' && value !== null ? (value as Record) : undefined + } + + const parseStatusCodeFromMessage = (message?: string): number | undefined => { + const match = message?.match(/status code:\s*(\d{3})/i) + if (!match?.[1]) { + return undefined + } + + return parseStatusCode(match[1]) + } + + const normalizeConsentActionError = (value: unknown): ConsentActionError | undefined => { + if (!value) { + return undefined + } + + if (typeof value === 'string') { + return createConsentActionError({ + message: value, + status: parseStatusCodeFromMessage(value), + cause: value, + }) + } + + const errorRecord = getErrorRecord(value) + if (!errorRecord) { + return createConsentActionError({ + message: String(value), + cause: value, + }) + } + + const causeRecord = getErrorRecord(errorRecord['cause']) + const message = + typeof errorRecord['message'] === 'string' + ? errorRecord['message'] + : typeof causeRecord?.['message'] === 'string' + ? causeRecord['message'] + : undefined + + return createConsentActionError({ + code: typeof errorRecord['code'] === 'string' ? errorRecord['code'] : undefined, + message, + status: + parseStatusCode(errorRecord['status']) ?? + parseStatusCode(errorRecord['statusCode']) ?? + parseStatusCode(causeRecord?.['status']) ?? + parseStatusCode(causeRecord?.['statusCode']) ?? + parseStatusCodeFromMessage(message), + statusText: + typeof errorRecord['statusText'] === 'string' + ? errorRecord['statusText'] + : typeof causeRecord?.['statusText'] === 'string' + ? causeRecord['statusText'] + : undefined, + cause: value, + }) + } + + const isSecurityCheckpointError = (error?: ConsentActionError): boolean => { + if (typeof error?.message !== 'string') { + return false + } + + const normalizedMessage = error.message.toLowerCase() + const hasCheckpointMarkup = + normalizedMessage.includes('vercel security checkpoint') || + (normalizedMessage.includes(' { if (typeof window === 'undefined' || onlineListener) { return @@ -503,27 +618,30 @@ export function initConsentSideEffects(): void { } const sendConsentPayload = async (payload: ConsentLogPayload) => { - const { data, error } = await actions.gdpr.consentCreate(payload) + let actionResponse: Awaited> | undefined + let thrownActionError: unknown + + try { + actionResponse = await actions.gdpr.consentCreate(payload) + } catch (error) { + thrownActionError = error + } + + const data = actionResponse?.data + const error = actionResponse?.error if (!error && data?.success) { return } - const actionError = error as - | { - code?: string - message?: string - status?: number - statusText?: string - } - | undefined + const actionError = normalizeConsentActionError(error ?? thrownActionError) const serverMessage = typeof actionError?.message === 'string' && actionError.message.trim().length > 0 ? actionError.message : undefined - if (actionError?.code === 'TOO_MANY_REQUESTS') { + if (actionError?.code === 'TOO_MANY_REQUESTS' || actionError?.status === 429) { throw new ConsentLogRetryableError( serverMessage ?? 'Consent logging is temporarily rate limited', parseRetryAfterMs(undefined, serverMessage), @@ -535,6 +653,14 @@ export function initConsentSideEffects(): void { ) } + if (isSecurityCheckpointError(actionError)) { + // Consent logging is best-effort. If Vercel blocks the action behind a + // checkpoint page, disable further attempts for this session without + // surfacing user-invisible noise to Sentry. + hasConsentLoggingFailure = true + return + } + throw new ClientScriptError({ message: serverMessage ?? 'Failed to record consent', cause: {