Skip to content
File

Blob: src/client/lib/report-client-error.ts

typescript169 lines
1import type { PublicClientConfig } from "@/shared/types";
2 
3type ClientErrorContext = Record<string, boolean | number | string | null | undefined>;
4 
5interface ClientErrorReport {
6 source: string;
7 error: unknown;
8 context?: ClientErrorContext;
9}
10 
11type NormalizedClientErrorReport = {
12 source: string;
13 error: Error;
14 context: ClientErrorContext;
15};
16 
17type SentryModule = typeof import("@sentry/react");
18 
19const EXPECTED_API_ERRORS = new Set(["validation_error", "unauthorized", "forbidden", "not_found"]);
20const MAX_PENDING_REPORTS = 50;
21 
22let pendingReports: NormalizedClientErrorReport[] = [];
23let reporterState: "pending" | "initializing" | "enabled" | "disabled" = "pending";
24let reporterInitPromise: Promise<void> | null = null;
25let sentryModule: SentryModule | null = null;
26 
27function 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 
35function shouldCaptureError(error: unknown): boolean {
36 const apiErrorCode = getApiErrorCode(error);
37 return apiErrorCode === null || !EXPECTED_API_ERRORS.has(apiErrorCode);
38}
39 
40function 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 
68function 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 
79function enqueuePendingReport(report: NormalizedClientErrorReport) {
80 pendingReports.push(report);
81 if (pendingReports.length > MAX_PENDING_REPORTS) {
82 pendingReports.shift();
83 }
84}
85 
86function 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 
101function 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 
114export 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 
149export 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}