From 2abfd556c2f403cbeff32ee0353b3ba2cc8fd75f Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Mon, 15 Jun 2026 17:40:42 +0200 Subject: [PATCH] feat(telemetry): add PII-free categorizeError util Derives { errorCode, errorType } from an error of unknown shape using a fixed taxonomy and HTTP status, never returning the raw message. Used to attach error categories to telemetry without leaking PII. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lib/telemetry/categorizeError.test.ts | 68 ++++++++++ apps/studio/lib/telemetry/categorizeError.ts | 120 ++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 apps/studio/lib/telemetry/categorizeError.test.ts create mode 100644 apps/studio/lib/telemetry/categorizeError.ts diff --git a/apps/studio/lib/telemetry/categorizeError.test.ts b/apps/studio/lib/telemetry/categorizeError.test.ts new file mode 100644 index 00000000000..f984aae6430 --- /dev/null +++ b/apps/studio/lib/telemetry/categorizeError.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' + +import { ConnectionTimeoutError } from '@/types/api-errors' +import { ResponseError } from '@/types/base' +import { categorizeError } from './categorizeError' + +describe('categorizeError', () => { + it('uses the explicit errorType and code from a classified API error', () => { + const error = new ConnectionTimeoutError('upstream request timeout', 504) + expect(categorizeError(error)).toEqual({ + errorCode: 504, + errorType: 'connection-timeout', + }) + }) + + it('maps a ResponseError status code to a taxonomy value', () => { + expect(categorizeError(new ResponseError('Unauthorized', 401))).toEqual({ + errorCode: 401, + errorType: 'unauthorized', + }) + expect(categorizeError(new ResponseError('Forbidden', 403))).toEqual({ + errorCode: 403, + errorType: 'forbidden', + }) + expect(categorizeError(new ResponseError('Server boom', 500))).toEqual({ + errorCode: 500, + errorType: 'server-error', + }) + }) + + it('classifies a plain object carrying a numeric code/status', () => { + expect(categorizeError({ message: 'nope', status: 404 })).toEqual({ + errorCode: 404, + errorType: 'not-found', + }) + }) + + it('classifies transport failures from the message without leaking it', () => { + expect(categorizeError('NetworkError when attempting to fetch resource')).toEqual({ + errorCode: 'network', + errorType: 'network-error', + }) + expect(categorizeError({ message: 'Request timed out' })).toEqual({ + errorCode: 'timeout', + errorType: 'timeout', + }) + expect(categorizeError('Canceled')).toEqual({ errorType: 'canceled' }) + }) + + it('extracts an embedded status code from a message string', () => { + expect(categorizeError('Failed with 503 Service Unavailable')).toEqual({ + errorCode: 503, + errorType: 'server-error', + }) + }) + + it('never returns the raw message and falls back to unknown', () => { + const result = categorizeError('Failed to load table public.users for user a@b.com') + expect(result).toEqual({ errorType: 'unknown' }) + expect(JSON.stringify(result)).not.toContain('a@b.com') + expect(JSON.stringify(result)).not.toContain('public.users') + }) + + it('handles null/undefined defensively', () => { + expect(categorizeError(null)).toEqual({ errorType: 'unknown' }) + expect(categorizeError(undefined)).toEqual({ errorType: 'unknown' }) + }) +}) diff --git a/apps/studio/lib/telemetry/categorizeError.ts b/apps/studio/lib/telemetry/categorizeError.ts new file mode 100644 index 00000000000..3b5bcd8b172 --- /dev/null +++ b/apps/studio/lib/telemetry/categorizeError.ts @@ -0,0 +1,120 @@ +import { ResponseError } from '@/types/base' + +/** + * PII-free telemetry properties derived from an error. + * + * `errorCode` is an HTTP status (e.g. 403, 500) or a transport category when no + * status is available (`'network'`, `'timeout'`). `errorType` is always a value + * from a fixed taxonomy. Neither field ever contains the raw error message, so + * the result is safe to send to PostHog where messages (which may contain SQL, + * table names, emails, etc.) must not be stored. + */ +export type ErrorTelemetryProps = { + errorCode?: number | string + errorType?: string +} + +const CANCELED_PATTERNS = ['canceled', 'cancelled', 'aborted', 'aborterror'] +const NETWORK_PATTERNS = [ + 'networkerror', + 'failed to fetch', + 'load failed', + 'network request failed', +] +const TIMEOUT_PATTERNS = ['timeout', 'timed out'] + +function errorTypeFromStatus(code: number): string | undefined { + switch (code) { + case 401: + return 'unauthorized' + case 403: + return 'forbidden' + case 404: + return 'not-found' + case 413: + return 'payload-too-large' + case 429: + return 'rate-limited' + } + if (code >= 500) return 'server-error' + if (code >= 400) return 'client-error' + return undefined +} + +function getMessage(error: unknown): string { + if (typeof error === 'string') return error + const hasStringMessage = + !!error && + typeof error === 'object' && + 'message' in error && + typeof (error as { message: unknown }).message === 'string' + return hasStringMessage ? (error as { message: string }).message : '' +} + +function getNumericCode(error: unknown): number | undefined { + if (!error || typeof error !== 'object') return undefined + const candidate = (error as { code?: unknown; status?: unknown }).code ?? + (error as { status?: unknown }).status + return typeof candidate === 'number' ? candidate : undefined +} + +/** + * Best-effort classification from a message string only. Matches known transport + * phrases and any embedded 4xx/5xx status. Returns a number/category, never the + * message itself. + */ +function categorizeFromMessage(message: string): ErrorTelemetryProps { + const lower = message.toLowerCase() + + if (CANCELED_PATTERNS.some((pattern) => lower.includes(pattern))) { + return { errorType: 'canceled' } + } + if (NETWORK_PATTERNS.some((pattern) => lower.includes(pattern))) { + return { errorCode: 'network', errorType: 'network-error' } + } + if (TIMEOUT_PATTERNS.some((pattern) => lower.includes(pattern))) { + return { errorCode: 'timeout', errorType: 'timeout' } + } + + const statusMatch = message.match(/\b(4\d{2}|5\d{2})\b/) + if (statusMatch) { + const status = Number(statusMatch[1]) + return { errorCode: status, errorType: errorTypeFromStatus(status) } + } + + return {} +} + +/** + * Derives PII-free `{ errorCode, errorType }` telemetry properties from an error + * of unknown shape, for use on the `dashboard_error_created` event. + * + * Precedence: + * 1. Classified API errors (`ConnectionTimeoutError`, etc.) carry an explicit + * `errorType` and an HTTP `code`. + * 2. Otherwise, a numeric `code`/`status` on the object maps to a status-class + * taxonomy. + * 3. Otherwise, the message is matched against known transport phrases and any + * embedded status code. + * 4. Falls back to `errorType: 'unknown'`. + */ +export function categorizeError(error: unknown): ErrorTelemetryProps { + if (error instanceof ResponseError) { + const classifiedType = (error as ResponseError & { errorType?: string }).errorType + const statusType = error.code !== undefined ? errorTypeFromStatus(error.code) : undefined + const fromMessage = categorizeFromMessage(error.message) + return { + errorCode: error.code ?? fromMessage.errorCode, + errorType: classifiedType ?? statusType ?? fromMessage.errorType ?? 'unknown', + } + } + + const numericCode = getNumericCode(error) + const fromMessage = categorizeFromMessage(getMessage(error)) + const statusType = numericCode !== undefined ? errorTypeFromStatus(numericCode) : undefined + + return { + errorCode: numericCode ?? fromMessage.errorCode, + errorType: statusType ?? fromMessage.errorType ?? 'unknown', + } +}