diff --git a/apps/backend/src/polyfills.tsx b/apps/backend/src/polyfills.tsx index cc42cddde..dcac8a193 100644 --- a/apps/backend/src/polyfills.tsx +++ b/apps/backend/src/polyfills.tsx @@ -10,13 +10,13 @@ function expandStackPortPrefix(value?: string | null) { return prefix ? value.replace(/\$\{NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81\}/g, prefix) : value; } -const sentryErrorSink = (location: string, error: unknown) => { +const sentryErrorSink = (location: string, error: unknown, level: "error" | "warning") => { if (!("captureException" in Sentry)) { // this happens if somehow this is called outside of a Next.js script (eg. in the Prisma seed.ts), just log and ignore console.log("Attempted to capture Sentry error outside of Next.js script, ignoring"); return; } - Sentry.captureException(error, { extra: { location } }); + Sentry.captureException(error, { extra: { location }, level }); runAsynchronouslyAndWaitUntil(Sentry.flush()); }; diff --git a/apps/dashboard/src/polyfills.tsx b/apps/dashboard/src/polyfills.tsx index 4830829f3..3fb108a36 100644 --- a/apps/dashboard/src/polyfills.tsx +++ b/apps/dashboard/src/polyfills.tsx @@ -10,8 +10,8 @@ function expandStackPortPrefix(value?: string | null) { return prefix ? value.replace(/\$\{NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81\}/g, prefix as string) : value; } -const sentryErrorSink = (location: string, error: unknown) => { - Sentry.captureException(error, { extra: { location } }); +const sentryErrorSink = (location: string, error: unknown, level: "error" | "warning") => { + Sentry.captureException(error, { extra: { location }, level }); }; export function ensurePolyfilled() { diff --git a/packages/stack-cli/src/lib/sentry.ts b/packages/stack-cli/src/lib/sentry.ts index 31f4ab96d..4b2b83a2d 100644 --- a/packages/stack-cli/src/lib/sentry.ts +++ b/packages/stack-cli/src/lib/sentry.ts @@ -89,8 +89,8 @@ export function initSentry() { }, }); - registerErrorSink((location, error) => { - Sentry.captureException(error, { extra: { location } }); + registerErrorSink((location, error, level) => { + Sentry.captureException(error, { extra: { location }, level }); ignoreUnhandledRejection(Sentry.flush(2000)); }); } diff --git a/packages/stack-shared/src/utils/errors.tsx b/packages/stack-shared/src/utils/errors.tsx index 16a237504..9e979a8a9 100644 --- a/packages/stack-shared/src/utils/errors.tsx +++ b/packages/stack-shared/src/utils/errors.tsx @@ -94,16 +94,23 @@ export function errorToNiceString(error: unknown): string { } -const errorSinks = new Set<(location: string, error: unknown, ...extraArgs: unknown[]) => void>(); -export function registerErrorSink(sink: (location: string, error: unknown) => void): void { +export type CaptureLevel = "error" | "warning"; + +type ErrorSink = (location: string, error: unknown, level: CaptureLevel, ...extraArgs: unknown[]) => void; + +const errorSinks = new Set(); +export function registerErrorSink(sink: ErrorSink): void { if (errorSinks.has(sink)) { return; } errorSinks.add(sink); } -registerErrorSink((location, error, ...extraArgs) => { - console.error( - `\x1b[41mCaptured error in ${location}:`, +registerErrorSink((location, error, level, ...extraArgs) => { + const logger = level === "warning" ? console.warn : console.error; + const colorCode = level === "warning" ? "\x1b[43m" : "\x1b[41m"; + const label = level === "warning" ? "warning" : "error"; + logger( + `${colorCode}Captured ${label} in ${location}:`, // HACK: Log a nicified version of the error to get around buggy Next.js pretty-printing // https://www.reddit.com/r/nextjs/comments/1gkxdqe/comment/m19kxgn/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button errorToNiceString(error), @@ -111,11 +118,22 @@ registerErrorSink((location, error, ...extraArgs) => { "\x1b[0m", ); }); -registerErrorSink((location, error, ...extraArgs) => { +registerErrorSink((location, error, level, ...extraArgs) => { globalVar.stackCapturedErrors = globalVar.stackCapturedErrors ?? []; - globalVar.stackCapturedErrors.push({ location, error, extraArgs }); + globalVar.stackCapturedErrors.push({ location, error, level, extraArgs }); }); +function dispatchToSinks(location: string, error: unknown, level: CaptureLevel): void { + for (const sink of errorSinks) { + sink( + location, + error, + level, + ...error && (typeof error === 'object' || typeof error === 'function') && "customCaptureExtraArgs" in error && Array.isArray(error.customCaptureExtraArgs) ? (error.customCaptureExtraArgs as any[]) : [], + ); + } +} + /** * Captures an error and sends it to the error sinks (most notably, Sentry). Errors caught with captureError are * supposed to be seen by an engineer, so they should be actionable and important. @@ -126,13 +144,15 @@ registerErrorSink((location, error, ...extraArgs) => { * Errors that bubble up to the top of runAsynchronously or a route handler are already captured with captureError. */ export function captureError(location: string, error: unknown): void { - for (const sink of errorSinks) { - sink( - location, - error, - ...error && (typeof error === 'object' || typeof error === 'function') && "customCaptureExtraArgs" in error && Array.isArray(error.customCaptureExtraArgs) ? (error.customCaptureExtraArgs as any[]) : [], - ); - } + dispatchToSinks(location, error, "error"); +} + +/** + * Like captureError, but logs at warning level. Use for issues that an engineer should know about but that aren't + * severe enough to be treated as an error (e.g. recoverable failures in background tasks). + */ +export function captureWarning(location: string, error: unknown): void { + dispatchToSinks(location, error, "warning"); } diff --git a/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts b/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts index f12927c14..a4115bdba 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts @@ -1,4 +1,5 @@ import { isBrowserLike } from "@stackframe/stack-shared/dist/utils/env"; +import { captureWarning } from "@stackframe/stack-shared/dist/utils/errors"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { Result } from "@stackframe/stack-shared/dist/utils/results"; @@ -255,12 +256,12 @@ export class SessionRecorder { ); if (res.status === "error") { - console.warn("SessionRecorder flush failed:", res.error); + captureWarning("SessionRecorder.flush", res.error); return; } if (!res.data.ok) { - console.warn("SessionRecorder flush failed:", res.data.status, await res.data.text()); + captureWarning("SessionRecorder.flush", new Error(`SessionRecorder flush failed: ${res.data.status} ${await res.data.text()}`)); } } finally { this._flushInProgress = false;