File
Blob: src/client/lib/report-client-error.ts
| 1 | import type { PublicClientConfig } from "@/shared/types"; |
| 2 | |
| 3 | type ClientErrorContext = Record<string, boolean | number | string | null | undefined>; |
| 4 | |
| 5 | interface ClientErrorReport { |
| 6 | source: string; |
| 7 | error: unknown; |
| 8 | context?: ClientErrorContext; |
| 9 | } |
| 10 | |
| 11 | type NormalizedClientErrorReport = { |
| 12 | source: string; |
| 13 | error: Error; |
| 14 | context: ClientErrorContext; |
| 15 | }; |
| 16 | |
| 17 | type SentryModule = typeof import("@sentry/react"); |
| 18 | |
| 19 | const EXPECTED_API_ERRORS = new Set(["validation_error", "unauthorized", "forbidden", "not_found"]); |
| 20 | const MAX_PENDING_REPORTS = 50; |
| 21 | |
| 22 | let pendingReports: NormalizedClientErrorReport[] = []; |
| 23 | let reporterState: "pending" | "initializing" | "enabled" | "disabled" = "pending"; |
| 24 | let reporterInitPromise: Promise<void> | null = null; |
| 25 | let sentryModule: SentryModule | null = null; |
| 26 | |
| 27 | function getApiErrorCode(error: unknown): string | null { |
| 28 | if (!error || typeof error !== "object" || !("error" in error)) { |
| 29 | return null; |
| 30 | } |
| 31 | |
| 32 | return typeof error.error === "string" ? error.error : null; |
| 33 | } |
| 34 | |
| 35 | function shouldCaptureError(error: unknown): boolean { |
| 36 | const apiErrorCode = getApiErrorCode(error); |
| 37 | return apiErrorCode === null || !EXPECTED_API_ERRORS.has(apiErrorCode); |
| 38 | } |
| 39 | |
| 40 | function normalizeError(error: unknown): Error { |
| 41 | if (error instanceof Error) { |
| 42 | return error; |
| 43 | } |
| 44 | |
| 45 | if (typeof error === "string") { |
| 46 | return new Error(error); |
| 47 | } |
| 48 | |
| 49 | const apiErrorCode = getApiErrorCode(error); |
| 50 | const message = |
| 51 | error && typeof error === "object" && "message" in error && typeof error.message === "string" |
| 52 | ? error.message |
| 53 | : (() => { |
| 54 | try { |
| 55 | return JSON.stringify(error); |
| 56 | } catch { |
| 57 | return null; |
| 58 | } |
| 59 | })(); |
| 60 | |
| 61 | const normalized = new Error(message || "Unknown client error"); |
| 62 | if (apiErrorCode) { |
| 63 | normalized.name = apiErrorCode; |
| 64 | } |
| 65 | return normalized; |
| 66 | } |
| 67 | |
| 68 | function normalizeReport({ source, error, context }: ClientErrorReport): NormalizedClientErrorReport { |
| 69 | return { |
| 70 | source, |
| 71 | error: normalizeError(error), |
| 72 | context: { |
| 73 | ...context, |
| 74 | ...(getApiErrorCode(error) ? { api_error: getApiErrorCode(error) } : {}), |
| 75 | }, |
| 76 | }; |
| 77 | } |
| 78 | |
| 79 | function enqueuePendingReport(report: NormalizedClientErrorReport) { |
| 80 | pendingReports.push(report); |
| 81 | if (pendingReports.length > MAX_PENDING_REPORTS) { |
| 82 | pendingReports.shift(); |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | function captureReport(report: NormalizedClientErrorReport) { |
| 87 | if (!sentryModule) { |
| 88 | return; |
| 89 | } |
| 90 | |
| 91 | sentryModule.withScope((scope) => { |
| 92 | scope.setTag("source", report.source); |
| 93 | const extras = Object.fromEntries(Object.entries(report.context).filter(([, value]) => value !== undefined)); |
| 94 | if (Object.keys(extras).length > 0) { |
| 95 | scope.setExtras(extras); |
| 96 | } |
| 97 | sentryModule?.captureException(report.error); |
| 98 | }); |
| 99 | } |
| 100 | |
| 101 | function flushPendingReports() { |
| 102 | if (!sentryModule || reporterState !== "enabled" || pendingReports.length === 0) { |
| 103 | return; |
| 104 | } |
| 105 | |
| 106 | const reports = pendingReports; |
| 107 | pendingReports = []; |
| 108 | |
| 109 | for (const report of reports) { |
| 110 | captureReport(report); |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | export function primeClientErrorReporting(config: PublicClientConfig | null) { |
| 115 | if (reporterState === "enabled" || reporterState === "disabled") { |
| 116 | return Promise.resolve(); |
| 117 | } |
| 118 | |
| 119 | if (reporterInitPromise) { |
| 120 | return reporterInitPromise; |
| 121 | } |
| 122 | |
| 123 | const sentryDsn = config?.sentry_dsn ?? null; |
| 124 | if (!sentryDsn) { |
| 125 | reporterState = "disabled"; |
| 126 | pendingReports = []; |
| 127 | return Promise.resolve(); |
| 128 | } |
| 129 | |
| 130 | reporterState = "initializing"; |
| 131 | reporterInitPromise = import("@sentry/react") |
| 132 | .then((nextSentryModule) => { |
| 133 | nextSentryModule.init({ dsn: sentryDsn, sendDefaultPii: false }); |
| 134 | sentryModule = nextSentryModule; |
| 135 | reporterState = "enabled"; |
| 136 | flushPendingReports(); |
| 137 | }) |
| 138 | .catch(() => { |
| 139 | reporterState = "disabled"; |
| 140 | pendingReports = []; |
| 141 | }) |
| 142 | .finally(() => { |
| 143 | reporterInitPromise = null; |
| 144 | }); |
| 145 | |
| 146 | return reporterInitPromise; |
| 147 | } |
| 148 | |
| 149 | export function reportClientError({ source, error, context }: ClientErrorReport) { |
| 150 | console.error(`[client:${source}]`, error, context ?? {}); |
| 151 | |
| 152 | if (!shouldCaptureError(error)) { |
| 153 | return; |
| 154 | } |
| 155 | |
| 156 | const normalized = normalizeReport({ source, error, context }); |
| 157 | |
| 158 | if (reporterState === "enabled" && sentryModule) { |
| 159 | captureReport(normalized); |
| 160 | return; |
| 161 | } |
| 162 | |
| 163 | if (reporterState === "disabled") { |
| 164 | return; |
| 165 | } |
| 166 | |
| 167 | enqueuePendingReport(normalized); |
| 168 | } |