diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts index e9fb92e8..e8fc3629 100644 --- a/src/lib/sentry.ts +++ b/src/lib/sentry.ts @@ -2,6 +2,7 @@ // ABOUTME: Provides crash reporting with stack traces, issue grouping, and performance metrics import * as Sentry from '@sentry/react'; +import { shouldDropHandledMediaHttpClientEvent } from '@/lib/sentryHttpClientFilter'; /** * Initialize Sentry error tracking @@ -105,6 +106,10 @@ export function initializeSentry() { // Don't send PII beforeSend(event) { + if (shouldDropHandledMediaHttpClientEvent(event)) { + return null; + } + // Scrub any potential PII from the event if (event.user) { // Only keep anonymized user ID (pubkey is already pseudonymous) diff --git a/src/lib/sentryHttpClientFilter.test.ts b/src/lib/sentryHttpClientFilter.test.ts new file mode 100644 index 00000000..f6342b02 --- /dev/null +++ b/src/lib/sentryHttpClientFilter.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { shouldDropHandledMediaHttpClientEvent } from '@/lib/sentryHttpClientFilter'; + +interface TestEventOptions { + url?: string; + statusCode?: number | string; + mechanismType?: string; + message?: string; +} + +function createHttpClientEvent({ + url = 'https://media.divine.video/hash/hls/master.m3u8', + statusCode = 401, + mechanismType = 'auto.http.client.fetch', + message, +}: TestEventOptions = {}) { + const resolvedMessage = message ?? `HTTP Client Error with status code: ${statusCode}`; + + return { + message: resolvedMessage, + request: { url }, + contexts: { response: { status_code: statusCode } }, + exception: { + values: [ + { + value: resolvedMessage, + mechanism: { + type: mechanismType, + }, + }, + ], + }, + }; +} + +describe('shouldDropHandledMediaHttpClientEvent', () => { + it('drops 401 failures for protected media assets', () => { + const event = createHttpClientEvent({ + url: 'https://media.divine.video/43debb/hls/master.m3u8', + statusCode: 401, + }); + + expect(shouldDropHandledMediaHttpClientEvent(event)).toBe(true); + }); + + it('drops 403 failures for protected media assets', () => { + const event = createHttpClientEvent({ + url: 'https://media.divine.video/43debb/downloads/default.mp4', + statusCode: 403, + }); + + expect(shouldDropHandledMediaHttpClientEvent(event)).toBe(true); + }); + + it('drops 404 failures for subtitle endpoints', () => { + const event = createHttpClientEvent({ + url: 'https://media.divine.video/f423713/vtt', + statusCode: 404, + }); + + expect(shouldDropHandledMediaHttpClientEvent(event)).toBe(true); + }); + + it('drops 422 failures for subtitle files', () => { + const event = createHttpClientEvent({ + url: 'https://media.divine.video/f423713/captions/en.vtt', + statusCode: 422, + }); + + expect(shouldDropHandledMediaHttpClientEvent(event)).toBe(true); + }); + + it('drops 404 failures for optional preview assets', () => { + const event = createHttpClientEvent({ + url: 'https://media.divine.video/f423713/poster.jpg', + statusCode: 404, + }); + + expect(shouldDropHandledMediaHttpClientEvent(event)).toBe(true); + }); + + it('keeps media 404 failures for non-optional playback assets', () => { + const event = createHttpClientEvent({ + url: 'https://media.divine.video/f423713/hls/master.m3u8', + statusCode: 404, + }); + + expect(shouldDropHandledMediaHttpClientEvent(event)).toBe(false); + }); + + it('keeps media 5xx failures visible', () => { + const event = createHttpClientEvent({ + url: 'https://media.divine.video/f423713/hls/master.m3u8', + statusCode: 500, + }); + + expect(shouldDropHandledMediaHttpClientEvent(event)).toBe(false); + }); + + it('keeps failures from non-media hosts', () => { + const event = createHttpClientEvent({ + url: 'https://api.divine.video/api/videos', + statusCode: 401, + }); + + expect(shouldDropHandledMediaHttpClientEvent(event)).toBe(false); + }); + + it('keeps non-http-client errors even when URL/status match', () => { + const event = createHttpClientEvent({ + url: 'https://media.divine.video/43debb/hls/master.m3u8', + statusCode: 401, + mechanismType: 'generic', + message: 'Something else happened', + }); + + expect(shouldDropHandledMediaHttpClientEvent(event)).toBe(false); + }); + + it('uses message fallback when contexts.response.status_code is missing', () => { + const event = createHttpClientEvent({ + url: 'https://media.divine.video/f423713/vtt', + statusCode: 422, + }); + (event.contexts.response as { status_code?: number | string }).status_code = undefined; + + expect(shouldDropHandledMediaHttpClientEvent(event)).toBe(true); + }); + + it('keeps events with invalid URLs', () => { + const event = createHttpClientEvent({ + url: 'not-a-valid-url', + statusCode: 401, + }); + + expect(shouldDropHandledMediaHttpClientEvent(event)).toBe(false); + }); +}); diff --git a/src/lib/sentryHttpClientFilter.ts b/src/lib/sentryHttpClientFilter.ts new file mode 100644 index 00000000..7f1747b2 --- /dev/null +++ b/src/lib/sentryHttpClientFilter.ts @@ -0,0 +1,161 @@ +type SentryExceptionValue = { + value?: unknown; + mechanism?: { + type?: unknown; + data?: Record; + }; +}; + +type SentryEventLike = { + message?: unknown; + request?: { + url?: unknown; + }; + contexts?: { + response?: { + status_code?: unknown; + }; + }; + exception?: { + values?: SentryExceptionValue[]; + }; +}; + +const MEDIA_HOSTNAME = 'media.divine.video'; +const HTTP_CLIENT_MESSAGE_RE = /^HTTP Client Error with status code:\s*(\d{3})$/i; +const OPTIONAL_IMAGE_EXTENSION_RE = /\.(?:avif|gif|jpe?g|png|webp)$/i; +const OPTIONAL_PREVIEW_PATH_SEGMENT_RE = /\/(?:poster|preview|thumbnail|thumb)(?:\/|$)/i; +const SUBTITLE_PATH_RE = /(?:\/vtt(?:\/|$)|\.vtt$)/i; + +function toSafeString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function parseStatusFromHttpClientMessage(message: string | null): number | null { + if (!message) return null; + const match = message.match(HTTP_CLIENT_MESSAGE_RE); + if (!match) return null; + const statusCode = Number.parseInt(match[1], 10); + return Number.isFinite(statusCode) ? statusCode : null; +} + +function extractStatusCode(event: SentryEventLike): number | null { + const rawStatusCode = event.contexts?.response?.status_code; + if (typeof rawStatusCode === 'number' && Number.isFinite(rawStatusCode)) { + return rawStatusCode; + } + if (typeof rawStatusCode === 'string' && rawStatusCode.trim().length > 0) { + const parsedStatusCode = Number.parseInt(rawStatusCode, 10); + if (Number.isFinite(parsedStatusCode)) { + return parsedStatusCode; + } + } + + const statusFromMessage = parseStatusFromHttpClientMessage(toSafeString(event.message)); + if (statusFromMessage !== null) { + return statusFromMessage; + } + + const exceptionValues = event.exception?.values ?? []; + for (const value of exceptionValues) { + const statusFromException = parseStatusFromHttpClientMessage(toSafeString(value.value)); + if (statusFromException !== null) { + return statusFromException; + } + } + + return null; +} + +function hasAutoHttpClientMechanism(event: SentryEventLike): boolean { + const exceptionValues = event.exception?.values ?? []; + + return exceptionValues.some((value) => { + const mechanismType = toSafeString(value.mechanism?.type); + if (mechanismType?.startsWith('auto.http.client.')) { + return true; + } + + const mechanismDataType = toSafeString(value.mechanism?.data?.type); + return mechanismDataType?.startsWith('auto.http.client.') ?? false; + }); +} + +function isHttpClientEvent(event: SentryEventLike): boolean { + if (hasAutoHttpClientMechanism(event)) { + return true; + } + + if (parseStatusFromHttpClientMessage(toSafeString(event.message)) !== null) { + return true; + } + + const exceptionValues = event.exception?.values ?? []; + return exceptionValues.some((value) => ( + parseStatusFromHttpClientMessage(toSafeString(value.value)) !== null + )); +} + +function isSubtitlePath(pathname: string): boolean { + return SUBTITLE_PATH_RE.test(pathname); +} + +function isOptionalPreviewPath(pathname: string): boolean { + if (OPTIONAL_PREVIEW_PATH_SEGMENT_RE.test(pathname)) { + return true; + } + + return OPTIONAL_IMAGE_EXTENSION_RE.test(pathname); +} + +/** + * Filters handled media fetch failures produced by Sentry's httpClientIntegration. + * + * Keep narrow allowlists to avoid hiding actionable production failures: + * - 401/403 on gated media assets + * - 404/422 on optional subtitles and preview/poster images + */ +export function shouldDropHandledMediaHttpClientEvent(event: SentryEventLike): boolean { + if (!isHttpClientEvent(event)) { + return false; + } + + const requestUrl = toSafeString(event.request?.url); + if (!requestUrl) { + return false; + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(requestUrl); + } catch { + return false; + } + + if (parsedUrl.hostname !== MEDIA_HOSTNAME) { + return false; + } + + const statusCode = extractStatusCode(event); + if (statusCode === null) { + return false; + } + + if (statusCode === 401 || statusCode === 403) { + return true; + } + + if (statusCode !== 404 && statusCode !== 422) { + return false; + } + + if (isSubtitlePath(parsedUrl.pathname)) { + return true; + } + + if (isOptionalPreviewPath(parsedUrl.pathname)) { + return true; + } + + return false; +}