mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 19:35:06 +03:00
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:
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',
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user