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) <noreply@anthropic.com>
This commit is contained in:
Jordi EnricandClaude Opus 4.8 committed 2026-06-15 17:40:42 +02:00
1 parent ebcd052018
commit 2abfd556c2
2 files changed
+188

No files matched your search

@@ -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' })
})
})
@@ -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',
}
}