From dffd9d9938281b62518dbd22410457f9a4f13f7a Mon Sep 17 00:00:00 2001 From: bombillazo Date: Sun, 25 Jan 2026 16:57:26 -0400 Subject: [PATCH 1/5] feat: add observability functions and types for error handling --- src/__tests__/observability.test.ts | 563 ++++++++++++++++++++++++++++ src/index.ts | 12 + src/observability.ts | 454 ++++++++++++++++++++++ 3 files changed, 1029 insertions(+) create mode 100644 src/__tests__/observability.test.ts create mode 100644 src/observability.ts diff --git a/src/__tests__/observability.test.ts b/src/__tests__/observability.test.ts new file mode 100644 index 0000000..67afa92 --- /dev/null +++ b/src/__tests__/observability.test.ts @@ -0,0 +1,563 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ErrorX } from '../error'; +import { + generateFingerprint, + recordError, + toLogEntry, + toOtelAttributes, + type OtelSpanLike, +} from '../observability'; + +describe('generateFingerprint', () => { + it('generates consistent fingerprints for identical errors', () => { + const error1 = new ErrorX({ + message: 'Database connection failed', + name: 'DatabaseError', + code: 'DB_CONN_FAILED', + }); + const error2 = new ErrorX({ + message: 'Database connection failed', + name: 'DatabaseError', + code: 'DB_CONN_FAILED', + }); + + const fp1 = generateFingerprint(error1); + const fp2 = generateFingerprint(error2); + + expect(fp1).toBe(fp2); + expect(fp1).toMatch(/^[a-f0-9]{8}$/); + }); + + it('generates different fingerprints for different error codes', () => { + const error1 = new ErrorX({ + message: 'Error occurred', + code: 'ERROR_A', + }); + const error2 = new ErrorX({ + message: 'Error occurred', + code: 'ERROR_B', + }); + + expect(generateFingerprint(error1)).not.toBe(generateFingerprint(error2)); + }); + + it('generates different fingerprints for different error names', () => { + const error1 = new ErrorX({ + message: 'Error occurred', + name: 'TypeA', + }); + const error2 = new ErrorX({ + message: 'Error occurred', + name: 'TypeB', + }); + + expect(generateFingerprint(error1)).not.toBe(generateFingerprint(error2)); + }); + + it('generates different fingerprints for different messages', () => { + const error1 = new ErrorX({ + message: 'Message A', + code: 'ERROR', + }); + const error2 = new ErrorX({ + message: 'Message B', + code: 'ERROR', + }); + + expect(generateFingerprint(error1)).not.toBe(generateFingerprint(error2)); + }); + + it('can exclude components from fingerprint', () => { + const error = new ErrorX({ + message: 'Same message', + name: 'DifferentName', + code: 'SAME_CODE', + }); + + // Without name, errors with same message and code should have same fingerprint + const fpWithoutName = generateFingerprint(error, { includeName: false }); + const error2 = new ErrorX({ + message: 'Same message', + name: 'AnotherName', + code: 'SAME_CODE', + }); + const fpWithoutName2 = generateFingerprint(error2, { includeName: false }); + + expect(fpWithoutName).toBe(fpWithoutName2); + }); + + it('can include specific metadata keys', () => { + const error1 = new ErrorX({ + message: 'Error', + code: 'ERROR', + metadata: { userId: 123, requestId: 'abc' }, + }); + const error2 = new ErrorX({ + message: 'Error', + code: 'ERROR', + metadata: { userId: 456, requestId: 'abc' }, + }); + + // Without metadata, fingerprints should match + expect(generateFingerprint(error1)).toBe(generateFingerprint(error2)); + + // With userId in fingerprint, they should differ + const fp1 = generateFingerprint(error1, { includeMetadataKeys: ['userId'] }); + const fp2 = generateFingerprint(error2, { includeMetadataKeys: ['userId'] }); + expect(fp1).not.toBe(fp2); + }); + + it('can use custom hash function', () => { + const error = new ErrorX({ message: 'Test', code: 'TEST' }); + const customHash = vi.fn().mockReturnValue('custom-hash'); + + const fp = generateFingerprint(error, { hashFunction: customHash }); + + expect(fp).toBe('custom-hash'); + expect(customHash).toHaveBeenCalledWith(expect.stringContaining('name:')); + }); + + it('handles errors without metadata gracefully', () => { + const error = new ErrorX({ message: 'No metadata' }); + + const fp = generateFingerprint(error, { includeMetadataKeys: ['nonexistent'] }); + expect(fp).toMatch(/^[a-f0-9]{8}$/); + }); +}); + +describe('toLogEntry', () => { + it('creates a structured log entry with required fields', () => { + const error = new ErrorX({ + message: 'User not found', + name: 'NotFoundError', + code: 'USER_NOT_FOUND', + httpStatus: 404, + metadata: { userId: 123 }, + }); + + const entry = toLogEntry(error); + + expect(entry.level).toBe('error'); + expect(entry.message).toBe('User not found'); + expect(entry.errorName).toBe('NotFoundError'); + expect(entry.errorCode).toBe('USER_NOT_FOUND'); + expect(entry.httpStatus).toBe(404); + expect(entry.metadata).toEqual({ userId: 123 }); + expect(entry.fingerprint).toMatch(/^[a-f0-9]{8}$/); + expect(entry.timestamp).toBe(error.timestamp); + expect(entry.timestampIso).toBe(new Date(error.timestamp).toISOString()); + expect(entry.chainDepth).toBe(1); + }); + + it('allows custom log level', () => { + const error = new ErrorX({ message: 'Warning' }); + + const warnEntry = toLogEntry(error, { level: 'warn' }); + expect(warnEntry.level).toBe('warn'); + + const infoEntry = toLogEntry(error, { level: 'info' }); + expect(infoEntry.level).toBe('info'); + }); + + it('includes stack trace when requested', () => { + const error = new ErrorX({ message: 'With stack' }); + + const entryWithoutStack = toLogEntry(error); + expect(entryWithoutStack.stack).toBeUndefined(); + + const entryWithStack = toLogEntry(error, { includeStack: true }); + expect(entryWithStack.stack).toBeDefined(); + expect(entryWithStack.stack).toContain('Error'); + }); + + it('includes full serialized error when requested', () => { + const error = new ErrorX({ + message: 'Full error', + code: 'FULL', + metadata: { test: true }, + }); + + const entry = toLogEntry(error, { includeFull: true }); + + expect(entry.error).toBeDefined(); + expect(entry.error?.message).toBe('Full error'); + expect(entry.error?.code).toBe('FULL'); + expect(entry.error?.metadata).toEqual({ test: true }); + }); + + it('includes root cause for chained errors', () => { + const rootCause = new ErrorX({ + message: 'Root cause', + name: 'RootError', + code: 'ROOT', + }); + const error = new ErrorX({ + message: 'Wrapper error', + name: 'WrapperError', + code: 'WRAPPER', + cause: rootCause, + }); + + const entry = toLogEntry(error); + + expect(entry.chainDepth).toBe(2); + expect(entry.rootCause).toEqual({ + name: 'RootError', + message: 'Root cause', + code: 'ROOT', + }); + }); + + it('merges additional context', () => { + const error = new ErrorX({ message: 'Test' }); + + const entry = toLogEntry(error, { + context: { + requestId: 'req-123', + service: 'api', + }, + }); + + expect((entry as Record).requestId).toBe('req-123'); + expect((entry as Record).service).toBe('api'); + }); + + it('omits undefined optional fields', () => { + const error = new ErrorX({ message: 'Minimal' }); + + const entry = toLogEntry(error); + + expect(entry.httpStatus).toBeUndefined(); + expect(entry.metadata).toBeUndefined(); + expect(entry.rootCause).toBeUndefined(); + expect(entry.stack).toBeUndefined(); + expect(entry.error).toBeUndefined(); + }); +}); + +describe('toOtelAttributes', () => { + it('creates OpenTelemetry-compatible attributes', () => { + const error = new ErrorX({ + message: 'Operation failed', + name: 'OperationError', + code: 'OP_FAILED', + httpStatus: 500, + }); + + const attrs = toOtelAttributes(error); + + expect(attrs['exception.type']).toBe('OperationError'); + expect(attrs['exception.message']).toBe('Operation failed'); + expect(attrs['error.code']).toBe('OP_FAILED'); + expect(attrs['error.fingerprint']).toMatch(/^[a-f0-9]{8}$/); + expect(attrs['http.status_code']).toBe(500); + expect(attrs['error.chain_depth']).toBe(1); + expect(attrs['error.is_aggregate']).toBe(false); + expect(attrs['error.timestamp']).toBe(error.timestamp); + }); + + it('includes stack trace by default', () => { + const error = new ErrorX({ message: 'With stack' }); + + const attrs = toOtelAttributes(error); + + expect(attrs['exception.stacktrace']).toBeDefined(); + }); + + it('can exclude stack trace', () => { + const error = new ErrorX({ message: 'Without stack' }); + + const attrs = toOtelAttributes(error, { includeStack: false }); + + expect(attrs['exception.stacktrace']).toBeUndefined(); + }); + + it('identifies aggregate errors', () => { + const errors = [ + new ErrorX({ message: 'Error 1' }), + new ErrorX({ message: 'Error 2' }), + new ErrorX({ message: 'Error 3' }), + ]; + const aggregate = ErrorX.aggregate(errors); + + const attrs = toOtelAttributes(aggregate); + + expect(attrs['error.is_aggregate']).toBe(true); + expect(attrs['error.aggregate_count']).toBe(3); + }); + + it('includes metadata as span attributes when requested', () => { + const error = new ErrorX({ + message: 'With metadata', + metadata: { + userId: 123, + action: 'delete', + isAdmin: true, + nested: { not: 'included' }, + }, + }); + + const attrs = toOtelAttributes(error, { includeMetadata: true }); + + expect(attrs['error.metadata.userId']).toBe(123); + expect(attrs['error.metadata.action']).toBe('delete'); + expect(attrs['error.metadata.isAdmin']).toBe(true); + // Nested objects are not included (only primitives) + expect(attrs['error.metadata.nested']).toBeUndefined(); + }); + + it('uses custom metadata prefix', () => { + const error = new ErrorX({ + message: 'Custom prefix', + metadata: { key: 'value' }, + }); + + const attrs = toOtelAttributes(error, { + includeMetadata: true, + metadataPrefix: 'app.context.', + }); + + expect(attrs['app.context.key']).toBe('value'); + expect(attrs['error.metadata.key']).toBeUndefined(); + }); + + it('omits http.status_code when not set', () => { + const error = new ErrorX({ message: 'No status' }); + + const attrs = toOtelAttributes(error); + + expect(attrs['http.status_code']).toBeUndefined(); + }); + + it('correctly reports chain depth for nested errors', () => { + const root = new ErrorX({ message: 'Root' }); + const middle = new ErrorX({ message: 'Middle', cause: root }); + const top = new ErrorX({ message: 'Top', cause: middle }); + + const attrs = toOtelAttributes(top); + + expect(attrs['error.chain_depth']).toBe(3); + }); +}); + +describe('recordError', () => { + it('returns attributes and applyToSpan helper', () => { + const error = new ErrorX({ + message: 'Test error', + code: 'TEST', + }); + + const result = recordError(error); + + expect(result.attributes).toBeDefined(); + expect(result.attributes['exception.type']).toBe('Error'); + expect(result.attributes['exception.message']).toBe('Test error'); + expect(typeof result.applyToSpan).toBe('function'); + }); + + it('applies error info to span', () => { + const error = new ErrorX({ + message: 'Span error', + code: 'SPAN_ERROR', + }); + + const mockSpan: OtelSpanLike = { + setAttributes: vi.fn(), + recordException: vi.fn(), + setStatus: vi.fn(), + }; + + const { applyToSpan } = recordError(error); + applyToSpan(mockSpan); + + expect(mockSpan.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + 'exception.type': 'Error', + 'exception.message': 'Span error', + 'error.code': 'SPAN_ERROR', + }) + ); + expect(mockSpan.recordException).toHaveBeenCalledWith(error); + expect(mockSpan.setStatus).toHaveBeenCalledWith({ + code: 2, // SpanStatusCode.ERROR + message: 'Span error', + }); + }); + + it('can skip recordException', () => { + const error = new ErrorX({ message: 'Test' }); + + const mockSpan: OtelSpanLike = { + setAttributes: vi.fn(), + recordException: vi.fn(), + setStatus: vi.fn(), + }; + + const { applyToSpan } = recordError(error); + applyToSpan(mockSpan, { recordException: false }); + + expect(mockSpan.setAttributes).toHaveBeenCalled(); + expect(mockSpan.recordException).not.toHaveBeenCalled(); + expect(mockSpan.setStatus).toHaveBeenCalled(); + }); + + it('can skip setStatus', () => { + const error = new ErrorX({ message: 'Test' }); + + const mockSpan: OtelSpanLike = { + setAttributes: vi.fn(), + recordException: vi.fn(), + setStatus: vi.fn(), + }; + + const { applyToSpan } = recordError(error); + applyToSpan(mockSpan, { setStatus: false }); + + expect(mockSpan.setAttributes).toHaveBeenCalled(); + expect(mockSpan.recordException).toHaveBeenCalled(); + expect(mockSpan.setStatus).not.toHaveBeenCalled(); + }); + + it('works with minimal span interface', () => { + const error = new ErrorX({ message: 'Test' }); + + // Minimal span with only setAttributes + const minimalSpan: OtelSpanLike = { + setAttributes: vi.fn(), + }; + + const { applyToSpan } = recordError(error); + + // Should not throw even without optional methods + expect(() => applyToSpan(minimalSpan)).not.toThrow(); + expect(minimalSpan.setAttributes).toHaveBeenCalled(); + }); + + it('passes through attribute options', () => { + const error = new ErrorX({ + message: 'Test', + metadata: { key: 'value' }, + }); + + const result = recordError(error, { includeMetadata: true }); + + expect(result.attributes['error.metadata.key']).toBe('value'); + }); +}); + +describe('integration scenarios', () => { + it('deduplication workflow with fingerprints', () => { + const seenFingerprints = new Set(); + const errors: ErrorX[] = []; + + // Simulate receiving multiple similar errors + for (let i = 0; i < 5; i++) { + const error = new ErrorX({ + message: 'Database connection timeout', + code: 'DB_TIMEOUT', + metadata: { attempt: i, host: 'db.example.com' }, + }); + + const fp = generateFingerprint(error); + + if (!seenFingerprints.has(fp)) { + seenFingerprints.add(fp); + errors.push(error); + } + } + + // All 5 errors should deduplicate to 1 + expect(errors.length).toBe(1); + expect(seenFingerprints.size).toBe(1); + }); + + it('structured logging workflow', () => { + const logs: unknown[] = []; + const mockLogger = { + error: (entry: unknown) => logs.push(entry), + }; + + const error = new ErrorX({ + message: 'API request failed', + code: 'API_ERROR', + httpStatus: 503, + metadata: { endpoint: '/users', method: 'GET' }, + }); + + const entry = toLogEntry(error, { + context: { + service: 'user-service', + environment: 'production', + }, + }); + + mockLogger.error(entry); + + expect(logs.length).toBe(1); + const logged = logs[0] as Record; + expect(logged.message).toBe('API request failed'); + expect(logged.service).toBe('user-service'); + expect(logged.fingerprint).toBeDefined(); + }); + + it('OpenTelemetry tracing workflow', () => { + const recordedAttributes: Record[] = []; + const recordedExceptions: Error[] = []; + const statuses: Array<{ code: number; message?: string }> = []; + + const mockSpan: OtelSpanLike = { + setAttributes: (attrs) => recordedAttributes.push(attrs), + recordException: (err) => recordedExceptions.push(err), + setStatus: (status) => statuses.push(status), + }; + + // Simulate an error in a traced operation + const dbError = new ErrorX({ + message: 'Query execution failed', + name: 'QueryError', + code: 'QUERY_FAILED', + metadata: { table: 'users', operation: 'SELECT' }, + }); + + const serviceError = new ErrorX({ + message: 'User lookup failed', + name: 'ServiceError', + code: 'USER_LOOKUP_FAILED', + httpStatus: 500, + cause: dbError, + }); + + const { applyToSpan } = recordError(serviceError, { includeMetadata: true }); + applyToSpan(mockSpan); + + expect(recordedAttributes.length).toBe(1); + expect(recordedAttributes[0]?.['exception.type']).toBe('ServiceError'); + expect(recordedAttributes[0]?.['error.chain_depth']).toBe(2); + expect(recordedExceptions.length).toBe(1); + expect(statuses[0]?.code).toBe(2); // ERROR + }); + + it('aggregate error observability', () => { + const validationErrors = [ + new ErrorX({ message: 'Email is required', code: 'VALIDATION_EMAIL' }), + new ErrorX({ message: 'Password too short', code: 'VALIDATION_PASSWORD' }), + new ErrorX({ message: 'Name is required', code: 'VALIDATION_NAME' }), + ]; + + const aggregate = ErrorX.aggregate(validationErrors, { + message: 'Validation failed', + code: 'VALIDATION_FAILED', + httpStatus: 400, + }); + + const logEntry = toLogEntry(aggregate, { includeFull: true }); + const otelAttrs = toOtelAttributes(aggregate); + + expect(logEntry.errorCode).toBe('VALIDATION_FAILED'); + expect(logEntry.httpStatus).toBe(400); + + expect(otelAttrs['error.is_aggregate']).toBe(true); + expect(otelAttrs['error.aggregate_count']).toBe(3); + expect(otelAttrs['http.status_code']).toBe(400); + }); +}); diff --git a/src/index.ts b/src/index.ts index ab35331..624e227 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,16 @@ export { AggregateErrorX, ErrorX, type ErrorXConfig } from './error'; +export { + generateFingerprint, + recordError, + toLogEntry, + toOtelAttributes, + type ErrorLogEntry, + type FingerprintOptions, + type LogEntryOptions, + type OtelAttributeOptions, + type OtelErrorAttributes, + type OtelSpanLike, +} from './observability'; export { type DBErrorPreset, DBErrorX, diff --git a/src/observability.ts b/src/observability.ts new file mode 100644 index 0000000..ec5bd0d --- /dev/null +++ b/src/observability.ts @@ -0,0 +1,454 @@ +import type { ErrorX } from './error'; +import type { ErrorXMetadata, ErrorXSerialized } from './types'; + +/** + * Options for generating error fingerprints. + * + * @public + */ +export type FingerprintOptions = { + /** Include error code in fingerprint (default: true) */ + includeCode?: boolean; + /** Include error name in fingerprint (default: true) */ + includeName?: boolean; + /** Include error message in fingerprint (default: true) */ + includeMessage?: boolean; + /** Include specific metadata keys in fingerprint */ + includeMetadataKeys?: string[]; + /** Custom hash function (default: simple string hash) */ + hashFunction?: (input: string) => string; +}; + +/** + * Structured log entry format for error logging. + * Compatible with structured logging libraries (pino, winston, bunyan). + * + * @public + */ +export type ErrorLogEntry = { + /** Log level */ + level: 'error' | 'warn' | 'info'; + /** Error message */ + message: string; + /** Error fingerprint for deduplication */ + fingerprint: string; + /** Error name/type */ + errorName: string; + /** Error code */ + errorCode: string; + /** Unix timestamp in milliseconds */ + timestamp: number; + /** ISO 8601 timestamp string */ + timestampIso: string; + /** HTTP status code if present */ + httpStatus?: number; + /** Error metadata */ + metadata?: ErrorXMetadata; + /** Stack trace (if includeStack is true) */ + stack?: string; + /** Error chain depth */ + chainDepth: number; + /** Root cause error info (if chain exists) */ + rootCause?: { + name: string; + message: string; + code: string; + }; + /** Full serialized error (if includeFull is true) */ + error?: ErrorXSerialized; +}; + +/** + * Options for creating structured log entries. + * + * @public + */ +export type LogEntryOptions = { + /** Log level (default: 'error') */ + level?: 'error' | 'warn' | 'info'; + /** Include stack trace (default: false) */ + includeStack?: boolean; + /** Include full serialized error (default: false) */ + includeFull?: boolean; + /** Fingerprint options */ + fingerprintOptions?: FingerprintOptions; + /** Additional context to merge into log entry */ + context?: Record; +}; + +/** + * OpenTelemetry-compatible span attributes for error tracking. + * Follows OpenTelemetry semantic conventions for exceptions. + * + * @see https://opentelemetry.io/docs/specs/semconv/exceptions/exceptions-spans/ + * @public + */ +export type OtelErrorAttributes = { + /** Exception type/name (semantic convention: exception.type) */ + 'exception.type': string; + /** Exception message (semantic convention: exception.message) */ + 'exception.message': string; + /** Exception stack trace (semantic convention: exception.stacktrace) */ + 'exception.stacktrace'?: string; + /** Error code (custom attribute) */ + 'error.code': string; + /** Error fingerprint for deduplication (custom attribute) */ + 'error.fingerprint': string; + /** HTTP status code if present */ + 'http.status_code'?: number; + /** Error chain depth */ + 'error.chain_depth': number; + /** Whether this is an aggregate error */ + 'error.is_aggregate': boolean; + /** Number of aggregated errors (if aggregate) */ + 'error.aggregate_count'?: number; + /** Timestamp when error was created */ + 'error.timestamp': number; +}; + +/** + * Options for creating OpenTelemetry span attributes. + * + * @public + */ +export type OtelAttributeOptions = { + /** Include stack trace (default: true) */ + includeStack?: boolean; + /** Include metadata as span attributes (default: false) */ + includeMetadata?: boolean; + /** Prefix for metadata attributes (default: 'error.metadata.') */ + metadataPrefix?: string; + /** Fingerprint options */ + fingerprintOptions?: FingerprintOptions; +}; + +/** + * Simple string hash function using djb2 algorithm. + * Produces a hex string hash for fingerprinting. + * + * @param str - Input string to hash + * @returns Hex string hash + * + * @internal + */ +const djb2Hash = (str: string): string => { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = (hash * 33) ^ str.charCodeAt(i); + } + // Convert to unsigned 32-bit integer and then to hex + return (hash >>> 0).toString(16).padStart(8, '0'); +}; + +/** + * Generates a unique fingerprint for an error for deduplication purposes. + * The fingerprint is based on stable error properties that identify the "type" + * of error rather than the specific instance. + * + * @param error - ErrorX instance to fingerprint + * @param options - Fingerprint generation options + * @returns Hex string fingerprint + * + * @example + * ```typescript + * const error = new ErrorX({ + * message: 'Database connection failed', + * name: 'DatabaseError', + * code: 'DB_CONN_FAILED' + * }) + * + * const fingerprint = generateFingerprint(error) + * // e.g., "a1b2c3d4" + * + * // Same error type always produces the same fingerprint + * const error2 = new ErrorX({ + * message: 'Database connection failed', + * name: 'DatabaseError', + * code: 'DB_CONN_FAILED' + * }) + * generateFingerprint(error2) === fingerprint // true + * ``` + * + * @public + */ +export const generateFingerprint = (error: ErrorX, options?: FingerprintOptions): string => { + const { + includeCode = true, + includeName = true, + includeMessage = true, + includeMetadataKeys = [], + hashFunction = djb2Hash, + } = options ?? {}; + + const parts: string[] = []; + + if (includeName) { + parts.push(`name:${error.name}`); + } + + if (includeCode) { + parts.push(`code:${error.code}`); + } + + if (includeMessage) { + parts.push(`message:${error.message}`); + } + + // Include specific metadata keys if requested + if (includeMetadataKeys.length > 0 && error.metadata) { + for (const key of includeMetadataKeys) { + if (key in error.metadata) { + const value = error.metadata[key]; + parts.push(`meta.${key}:${String(value)}`); + } + } + } + + const fingerprint = parts.join('|'); + return hashFunction(fingerprint); +}; + +/** + * Creates a structured log entry from an ErrorX instance. + * The entry is compatible with structured logging libraries like pino, winston, and bunyan. + * + * @param error - ErrorX instance to create log entry from + * @param options - Log entry creation options + * @returns Structured log entry object + * + * @example + * ```typescript + * import pino from 'pino' + * const logger = pino() + * + * const error = new ErrorX({ + * message: 'User not found', + * code: 'USER_NOT_FOUND', + * httpStatus: 404, + * metadata: { userId: 123 } + * }) + * + * const logEntry = toLogEntry(error) + * logger.error(logEntry) + * // Output: {"level":"error","message":"User not found","fingerprint":"abc123",...} + * + * // With full error for debugging + * const debugEntry = toLogEntry(error, { includeFull: true, includeStack: true }) + * ``` + * + * @public + */ +export const toLogEntry = (error: ErrorX, options?: LogEntryOptions): ErrorLogEntry => { + const { level = 'error', includeStack = false, includeFull = false, fingerprintOptions, context } = + options ?? {}; + + const fingerprint = generateFingerprint(error, fingerprintOptions); + const chain = error.chain; + const root = error.root; + + const entry: ErrorLogEntry = { + level, + message: error.message, + fingerprint, + errorName: error.name, + errorCode: error.code, + timestamp: error.timestamp, + timestampIso: new Date(error.timestamp).toISOString(), + chainDepth: chain.length, + }; + + if (error.httpStatus !== undefined) { + entry.httpStatus = error.httpStatus; + } + + if (error.metadata && Object.keys(error.metadata).length > 0) { + entry.metadata = error.metadata; + } + + if (includeStack && error.stack) { + entry.stack = error.stack; + } + + if (root) { + entry.rootCause = { + name: root.name, + message: root.message, + code: root.code, + }; + } + + if (includeFull) { + entry.error = error.toJSON(); + } + + // Merge additional context + if (context) { + return { ...entry, ...context } as ErrorLogEntry; + } + + return entry; +}; + +/** + * Creates OpenTelemetry-compatible span attributes from an ErrorX instance. + * Follows OpenTelemetry semantic conventions for exception tracking. + * + * @param error - ErrorX instance to create attributes from + * @param options - Attribute creation options + * @returns Object of span attributes following OTel conventions + * + * @example + * ```typescript + * import { trace } from '@opentelemetry/api' + * + * const tracer = trace.getTracer('my-service') + * const span = tracer.startSpan('operation') + * + * try { + * await riskyOperation() + * } catch (err) { + * const error = ErrorX.from(err) + * const attributes = toOtelAttributes(error) + * + * // Record exception event with attributes + * span.recordException(error) + * span.setAttributes(attributes) + * span.setStatus({ code: SpanStatusCode.ERROR, message: error.message }) + * } + * + * // With metadata as span attributes + * const attrs = toOtelAttributes(error, { + * includeMetadata: true, + * metadataPrefix: 'app.error.' + * }) + * // Includes: { 'app.error.userId': 123, ... } + * ``` + * + * @public + */ +export const toOtelAttributes = ( + error: ErrorX, + options?: OtelAttributeOptions +): OtelErrorAttributes & Record => { + const { + includeStack = true, + includeMetadata = false, + metadataPrefix = 'error.metadata.', + fingerprintOptions, + } = options ?? {}; + + const fingerprint = generateFingerprint(error, fingerprintOptions); + + // Check if this is an aggregate error by checking for 'errors' property + const isAggregate = 'errors' in error && Array.isArray((error as { errors?: unknown[] }).errors); + const aggregateCount = isAggregate + ? (error as { errors: unknown[] }).errors.length + : undefined; + + const attributes: OtelErrorAttributes & Record = { + 'exception.type': error.name, + 'exception.message': error.message, + 'error.code': error.code, + 'error.fingerprint': fingerprint, + 'error.chain_depth': error.chain.length, + 'error.is_aggregate': isAggregate, + 'error.timestamp': error.timestamp, + }; + + if (includeStack && error.stack) { + attributes['exception.stacktrace'] = error.stack; + } + + if (error.httpStatus !== undefined) { + attributes['http.status_code'] = error.httpStatus; + } + + if (isAggregate && aggregateCount !== undefined) { + attributes['error.aggregate_count'] = aggregateCount; + } + + // Include metadata as span attributes if requested + if (includeMetadata && error.metadata) { + for (const [key, value] of Object.entries(error.metadata)) { + // Only include primitive values (strings, numbers, booleans) + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + attributes[`${metadataPrefix}${key}`] = value; + } + } + } + + return attributes; +}; + +/** + * Helper to record an error on an OpenTelemetry span. + * This is a convenience function that creates attributes and provides + * a callback pattern for span manipulation. + * + * @param error - ErrorX instance to record + * @param options - Options including attribute options and span callback + * @returns Object with attributes and a helper to apply to a span + * + * @example + * ```typescript + * import { trace, SpanStatusCode } from '@opentelemetry/api' + * + * const span = tracer.startSpan('operation') + * + * try { + * await operation() + * } catch (err) { + * const error = ErrorX.from(err) + * const { attributes, applyToSpan } = recordError(error) + * + * // Apply all error info to span + * applyToSpan(span, { + * setStatus: true, + * recordException: true + * }) + * } + * ``` + * + * @public + */ +export const recordError = ( + error: ErrorX, + options?: OtelAttributeOptions +): { + attributes: OtelErrorAttributes & Record; + applyToSpan: ( + span: TSpan, + spanOptions?: { setStatus?: boolean; recordException?: boolean } + ) => void; +} => { + const attributes = toOtelAttributes(error, options); + + return { + attributes, + applyToSpan: (span, spanOptions = {}) => { + const { setStatus = true, recordException = true } = spanOptions; + + span.setAttributes(attributes); + + if (recordException && typeof span.recordException === 'function') { + span.recordException(error); + } + + if (setStatus && typeof span.setStatus === 'function') { + span.setStatus({ code: 2, message: error.message }); // SpanStatusCode.ERROR = 2 + } + }, + }; +}; + +/** + * Minimal interface matching OpenTelemetry Span for type compatibility. + * This allows using the helpers without requiring @opentelemetry/api as a dependency. + * + * @public + */ +export type OtelSpanLike = { + setAttributes: (attributes: Record) => void; + recordException?: (exception: Error) => void; + setStatus?: (status: { code: number; message?: string }) => void; +}; From 461865402dfcd160e5476cc93254232d9487dfc2 Mon Sep 17 00:00:00 2001 From: bombillazo Date: Sun, 25 Jan 2026 16:57:42 -0400 Subject: [PATCH 2/5] feat: add tRPC and Zod integration examples with error handling - Introduced `trpc-integration.ts` to demonstrate error handling integration with tRPC using error-x. - Implemented type-safe conversion utilities for ErrorX and TRPCError. - Created custom error formatter and middleware for tRPC. - Added client-side error handling and React Query integration for tRPC. - Introduced `zod-integration.ts` to showcase advanced Zod integration patterns with error-x. - Implemented validation utilities for Zod schemas, including safe parsing and aggregate error handling. - Added form validation helpers and API request validation examples. --- examples/README.md | 215 ++++++++++ examples/express-middleware.ts | 317 +++++++++++++++ examples/graphql-integration.ts | 636 ++++++++++++++++++++++++++++++ examples/hono-middleware.ts | 233 +++++++++++ examples/logging-integration.ts | 492 +++++++++++++++++++++++ examples/react-error-boundary.tsx | 538 +++++++++++++++++++++++++ examples/trpc-integration.ts | 545 +++++++++++++++++++++++++ examples/zod-integration.ts | 578 +++++++++++++++++++++++++++ 8 files changed, 3554 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/express-middleware.ts create mode 100644 examples/graphql-integration.ts create mode 100644 examples/hono-middleware.ts create mode 100644 examples/logging-integration.ts create mode 100644 examples/react-error-boundary.tsx create mode 100644 examples/trpc-integration.ts create mode 100644 examples/zod-integration.ts diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..548a707 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,215 @@ +# error-x Integration Examples + +This directory contains example integrations for error-x with popular frameworks and libraries. + +## Examples + +### Server Frameworks + +- **[hono-middleware.ts](./hono-middleware.ts)** - Hono.js error handling middleware +- **[express-middleware.ts](./express-middleware.ts)** - Express.js error handling middleware + +### Frontend + +- **[react-error-boundary.tsx](./react-error-boundary.tsx)** - React error boundary with ErrorX integration + +### API Frameworks + +- **[trpc-integration.ts](./trpc-integration.ts)** - tRPC error handling with ErrorX +- **[graphql-integration.ts](./graphql-integration.ts)** - GraphQL (Apollo/yoga) error formatting + +### Utilities + +- **[logging-integration.ts](./logging-integration.ts)** - Pino, Winston, and generic logging integration +- **[zod-integration.ts](./zod-integration.ts)** - Advanced Zod validation patterns + +## Quick Start + +### Hono.js + +```typescript +import { Hono } from 'hono' +import { HTTPErrorX, ValidationErrorX } from '@bombillazo/error-x' +import { errorMiddleware, requestIdMiddleware } from './examples/hono-middleware' + +const app = new Hono() + +app.use('*', requestIdMiddleware()) +app.onError(errorMiddleware()) + +app.get('/user/:id', async (c) => { + const user = await getUser(c.req.param('id')) + if (!user) { + throw HTTPErrorX.create(404, { message: 'User not found' }) + } + return c.json(user) +}) +``` + +### Express.js + +```typescript +import express from 'express' +import { HTTPErrorX } from '@bombillazo/error-x' +import { errorHandler, asyncHandler, notFoundHandler } from './examples/express-middleware' + +const app = express() + +app.get('/user/:id', asyncHandler(async (req, res) => { + const user = await getUser(req.params.id) + if (!user) { + throw HTTPErrorX.create(404) + } + res.json(user) +})) + +app.use(notFoundHandler()) +app.use(errorHandler()) +``` + +### React + +```tsx +import { ErrorBoundary, useErrorBoundary } from './examples/react-error-boundary' +import { HTTPErrorX } from '@bombillazo/error-x' + +const App = () => ( + trackError(error)}> + + +) + +const DataFetcher = () => { + const { reportError } = useErrorBoundary() + + const fetchData = async () => { + try { + const response = await fetch('/api/data') + if (!response.ok) throw HTTPErrorX.create(response.status) + return response.json() + } catch (err) { + reportError(err) + } + } + + // ... +} +``` + +### Logging + +```typescript +import pino from 'pino' +import { ErrorX, toLogEntry } from '@bombillazo/error-x' +import { createPinoConfig, createErrorDeduplicator } from './examples/logging-integration' + +const logger = pino(createPinoConfig()) +const dedup = createErrorDeduplicator() + +// In error handler +const error = ErrorX.from(caughtError) +if (dedup.shouldLog(error)) { + logger.error(toLogEntry(error, { includeStack: true })) +} +``` + +### tRPC + +```typescript +import { initTRPC } from '@trpc/server' +import { HTTPErrorX } from '@bombillazo/error-x' +import { createErrorFormatter, toTRPCError } from './examples/trpc-integration' + +const t = initTRPC.create({ + errorFormatter: createErrorFormatter() +}) + +const appRouter = t.router({ + user: t.router({ + get: t.procedure.input(z.object({ id: z.string() })).query(async ({ input }) => { + const user = await db.user.findUnique({ where: { id: input.id } }) + if (!user) throw toTRPCError(HTTPErrorX.create(404)) + return user + }) + }) +}) +``` + +### GraphQL + +```typescript +import { ApolloServer } from '@apollo/server' +import { GraphQLErrorX, createApolloErrorFormatter } from './examples/graphql-integration' + +const server = new ApolloServer({ + typeDefs, + resolvers, + formatError: createApolloErrorFormatter() +}) + +// In resolver +const resolvers = { + Query: { + user: async (_, { id }) => { + const user = await getUser(id) + if (!user) throw GraphQLErrorX.create('NOT_FOUND') + return user + } + } +} +``` + +### Zod Validation + +```typescript +import { z } from 'zod' +import { ValidationErrorX } from '@bombillazo/error-x' +import { validateRequest, validateForm } from './examples/zod-integration' + +const schema = z.object({ + email: z.string().email(), + age: z.number().min(18) +}) + +// API validation +app.post('/api/users', async (c) => { + const validation = validateRequest(schema, await c.req.json()) + if (!validation.success) { + return c.json(validation.errorResponse, 400) + } + // validation.data is typed +}) + +// Form validation +const result = validateForm(schema, formData) +if (!result.success) { + setErrors(result.fieldErrors) // { email: ['Invalid email'], age: ['Must be 18+'] } +} +``` + +## Note + +These examples are for reference and may require additional dependencies. Install the relevant framework/library before using: + +```bash +# Server frameworks +pnpm add hono +pnpm add express && pnpm add -D @types/express + +# React +pnpm add react react-dom + +# API frameworks +pnpm add @trpc/server @trpc/client +pnpm add @apollo/server graphql +# or +pnpm add graphql-yoga graphql + +# Logging +pnpm add pino +# or +pnpm add winston + +# Validation +pnpm add zod +``` diff --git a/examples/express-middleware.ts b/examples/express-middleware.ts new file mode 100644 index 0000000..79c0701 --- /dev/null +++ b/examples/express-middleware.ts @@ -0,0 +1,317 @@ +/** + * Express.js Error Middleware Example + * + * This example shows how to integrate error-x with Express.js for + * consistent error handling across your API. + * + * @example + * ```bash + * pnpm add express @bombillazo/error-x + * pnpm add -D @types/express + * ``` + */ + +import type { Request, Response, NextFunction, ErrorRequestHandler } from 'express' +import { + ErrorX, + HTTPErrorX, + DBErrorX, + ValidationErrorX, + toLogEntry, + type ErrorXSerialized, +} from '@bombillazo/error-x' + +// ============================================================================ +// Types +// ============================================================================ + +type ErrorResponse = { + success: false + error: { + code: string + message: string + timestamp: string + requestId?: string + } + details?: ErrorXSerialized +} + +type ExpressErrorMiddlewareOptions = { + /** Include stack traces and full error in response (default: false in prod) */ + includeDetails?: boolean + /** Custom logger function */ + logger?: (entry: ReturnType) => void + /** Header name for request ID (default: 'x-request-id') */ + requestIdHeader?: string + /** Custom error transformer before response */ + transformError?: (error: ErrorX, req: Request) => ErrorX +} + +// ============================================================================ +// Error Middleware +// ============================================================================ + +/** + * Creates an Express error handling middleware. + * Must be registered LAST after all routes. + * + * @example + * ```typescript + * app.use('/api', apiRouter) + * app.use(errorHandler()) // Must be last + * ``` + */ +export const errorHandler = (options: ExpressErrorMiddlewareOptions = {}): ErrorRequestHandler => { + const { + includeDetails = process.env.NODE_ENV !== 'production', + logger = (entry) => console.error(JSON.stringify(entry)), + requestIdHeader = 'x-request-id', + transformError, + } = options + + return (err: unknown, req: Request, res: Response, _next: NextFunction) => { + // Convert any error to ErrorX + let error = ErrorX.isErrorX(err) ? err : ErrorX.from(err) + + // Apply custom transformation if provided + if (transformError) { + error = transformError(error, req) + } + + // Get request ID + const requestId = req.headers[requestIdHeader] as string | undefined + + // Log the error with request context + const logEntry = toLogEntry(error, { + includeStack: true, + includeFull: true, + context: { + requestId, + method: req.method, + path: req.path, + ip: req.ip, + userAgent: req.headers['user-agent'], + }, + }) + logger(logEntry) + + // Determine HTTP status + const status = error.httpStatus ?? 500 + + // Build response + const response: ErrorResponse = { + success: false, + error: { + code: error.code, + message: error.message, + timestamp: new Date(error.timestamp).toISOString(), + ...(requestId && { requestId }), + }, + } + + // Include full error details in development + if (includeDetails) { + response.details = error.toJSON() + } + + res.status(status).json(response) + } +} + +/** + * Async handler wrapper - catches async errors and forwards to error middleware. + * + * @example + * ```typescript + * app.get('/user/:id', asyncHandler(async (req, res) => { + * const user = await getUser(req.params.id) + * res.json(user) + * })) + * ``` + */ +export const asyncHandler = ( + fn: (req: Request, res: Response, next: NextFunction) => Promise +) => { + return (req: Request, res: Response, next: NextFunction) => { + Promise.resolve(fn(req, res, next)).catch(next) + } +} + +/** + * Request ID middleware - adds a unique ID to each request. + */ +export const requestIdMiddleware = (headerName = 'x-request-id') => { + return (req: Request, res: Response, next: NextFunction) => { + const requestId = (req.headers[headerName] as string) ?? crypto.randomUUID() + req.headers[headerName] = requestId + res.setHeader(headerName, requestId) + next() + } +} + +/** + * Not found handler - creates 404 errors for unmatched routes. + * Should be registered after all routes but before error handler. + */ +export const notFoundHandler = () => { + return (req: Request, _res: Response, next: NextFunction) => { + const error = HTTPErrorX.create(404, { + message: `Route ${req.method} ${req.path} not found`, + metadata: { + method: req.method, + path: req.path, + }, + }) + next(error) + } +} + +// ============================================================================ +// Example Application Setup +// ============================================================================ + +/** + * Example setup showing complete Express integration: + * + * ```typescript + * import express from 'express' + * import { + * errorHandler, + * asyncHandler, + * requestIdMiddleware, + * notFoundHandler + * } from './express-middleware' + * import { HTTPErrorX, DBErrorX, ValidationErrorX, ErrorX } from '@bombillazo/error-x' + * + * const app = express() + * + * // Early middleware + * app.use(express.json()) + * app.use(requestIdMiddleware()) + * + * // Routes + * app.get('/user/:id', asyncHandler(async (req, res) => { + * const { id } = req.params + * + * if (id === '0') { + * throw HTTPErrorX.create(404, { + * message: 'User not found', + * metadata: { userId: id } + * }) + * } + * + * // Simulate DB error + * if (id === 'db-error') { + * throw DBErrorX.create('CONNECTION_TIMEOUT', { + * cause: new Error('ETIMEDOUT'), + * metadata: { host: 'db.example.com', timeout: 5000 } + * }) + * } + * + * res.json({ id, name: 'John Doe' }) + * })) + * + * app.post('/user', asyncHandler(async (req, res) => { + * const { email, password } = req.body + * + * if (!email) { + * throw ValidationErrorX.forField('email', 'Email is required') + * } + * + * if (!password || password.length < 8) { + * throw ValidationErrorX.forField('password', 'Password must be at least 8 characters') + * } + * + * res.status(201).json({ id: '123', email }) + * })) + * + * // Error handling - MUST be last + * app.use(notFoundHandler()) + * app.use(errorHandler({ + * logger: (entry) => { + * // Send to your logging service + * console.error(JSON.stringify(entry)) + * }, + * transformError: (error, req) => { + * // Add request context to metadata + * return error.withMetadata({ + * requestPath: req.path, + * requestMethod: req.method + * }) + * } + * })) + * + * app.listen(3000, () => console.log('Server running on port 3000')) + * ``` + */ + +// ============================================================================ +// Response Format Examples +// ============================================================================ + +/** + * ## Response Format + * + * All errors return a consistent JSON structure: + * + * ### Production Response (status: 404) + * ```json + * { + * "success": false, + * "error": { + * "code": "NOT_FOUND", + * "message": "User not found", + * "timestamp": "2024-01-01T00:00:00.000Z", + * "requestId": "abc-123" + * } + * } + * ``` + * + * ### Development Response (includes full details) + * ```json + * { + * "success": false, + * "error": { + * "code": "NOT_FOUND", + * "message": "User not found", + * "timestamp": "2024-01-01T00:00:00.000Z", + * "requestId": "abc-123" + * }, + * "details": { + * "name": "NotFoundError", + * "code": "NOT_FOUND", + * "message": "User not found", + * "httpStatus": 404, + * "timestamp": 1704067200000, + * "stack": "NotFoundError: User not found\n at ...", + * "metadata": { "userId": "0" }, + * "chain": [] + * } + * } + * ``` + * + * ### DB Error Response (status: 500) + * ```json + * { + * "success": false, + * "error": { + * "code": "DB_CONNECTION_TIMEOUT", + * "message": "Database connection timed out.", + * "timestamp": "2024-01-01T00:00:00.000Z" + * }, + * "details": { + * "name": "DatabaseError", + * "code": "DB_CONNECTION_TIMEOUT", + * "httpStatus": 500, + * "metadata": { "host": "db.example.com", "timeout": 5000 }, + * "chain": [ + * { "name": "DatabaseError", "code": "DB_CONNECTION_TIMEOUT", ... }, + * { "name": "Error", "message": "ETIMEDOUT", ... } + * ], + * "original": { "name": "Error", "message": "ETIMEDOUT", ... } + * } + * } + * ``` + */ + +export { ErrorX, HTTPErrorX, DBErrorX, ValidationErrorX } diff --git a/examples/graphql-integration.ts b/examples/graphql-integration.ts new file mode 100644 index 0000000..09c7c6c --- /dev/null +++ b/examples/graphql-integration.ts @@ -0,0 +1,636 @@ +/** + * GraphQL Error Formatting Integration Example + * + * This example shows how to integrate error-x with GraphQL servers + * (Apollo Server, graphql-yoga, etc.) for consistent error handling. + * + * @example + * ```bash + * pnpm add @apollo/server graphql @bombillazo/error-x + * # or + * pnpm add graphql-yoga graphql @bombillazo/error-x + * ``` + */ + +import { + ErrorX, + HTTPErrorX, + DBErrorX, + ValidationErrorX, + toLogEntry, + generateFingerprint, + type ErrorXSerialized, +} from '@bombillazo/error-x' +import { GraphQLError } from 'graphql' + +// ============================================================================ +// Types +// ============================================================================ + +/** + * GraphQL error extensions following best practices. + */ +type GraphQLErrorExtensions = { + code: string + httpStatus?: number + timestamp: string + fingerprint: string + metadata?: Record + // Development only + stack?: string + chain?: Array<{ code: string; message: string; name: string }> +} + +/** + * Options for error formatting. + */ +type ErrorFormatterOptions = { + /** Include stack trace (default: false in production) */ + includeStack?: boolean + /** Include error chain (default: false) */ + includeChain?: boolean + /** Mask internal errors with generic message */ + maskInternalErrors?: boolean + /** Logger function */ + logger?: (entry: ReturnType) => void +} + +// ============================================================================ +// Error Formatting +// ============================================================================ + +/** + * Convert ErrorX to GraphQL error extensions. + * + * @example + * ```typescript + * const error = HTTPErrorX.create(404) + * const extensions = toGraphQLExtensions(error) + * ``` + */ +export const toGraphQLExtensions = ( + error: ErrorX, + options?: ErrorFormatterOptions +): GraphQLErrorExtensions => { + const { includeStack = false, includeChain = false } = options ?? {} + + const extensions: GraphQLErrorExtensions = { + code: error.code, + httpStatus: error.httpStatus, + timestamp: new Date(error.timestamp).toISOString(), + fingerprint: generateFingerprint(error), + } + + if (error.metadata && Object.keys(error.metadata).length > 0) { + extensions.metadata = error.metadata + } + + if (includeStack && error.stack) { + extensions.stack = error.stack + } + + if (includeChain && error.chain.length > 1) { + extensions.chain = error.chain.map((e) => ({ + code: e.code, + message: e.message, + name: e.name, + })) + } + + return extensions +} + +/** + * Convert ErrorX to GraphQLError. + * + * @example + * ```typescript + * throw toGraphQLError(HTTPErrorX.create(404, { message: 'User not found' })) + * ``` + */ +export const toGraphQLError = (error: ErrorX, options?: ErrorFormatterOptions): GraphQLError => { + const { maskInternalErrors = true } = options ?? {} + + // Mask internal server errors in production + const isInternalError = error.httpStatus === 500 || !error.httpStatus + const shouldMask = maskInternalErrors && isInternalError && process.env.NODE_ENV === 'production' + + return new GraphQLError( + shouldMask ? 'Internal server error' : error.message, + { + extensions: toGraphQLExtensions(error, options), + } + ) +} + +/** + * Convert any error to GraphQLError through ErrorX. + * + * @example + * ```typescript + * try { + * await operation() + * } catch (err) { + * throw toGraphQLErrorFromAny(err) + * } + * ``` + */ +export const toGraphQLErrorFromAny = (err: unknown, options?: ErrorFormatterOptions): GraphQLError => { + const errorX = ErrorX.isErrorX(err) ? err : ErrorX.from(err) + return toGraphQLError(errorX, options) +} + +// ============================================================================ +// Apollo Server Integration +// ============================================================================ + +/** + * Create an Apollo Server format error function. + * + * @example + * ```typescript + * import { ApolloServer } from '@apollo/server' + * import { createApolloErrorFormatter } from './graphql-integration' + * + * const server = new ApolloServer({ + * typeDefs, + * resolvers, + * formatError: createApolloErrorFormatter({ + * logger: console.error + * }) + * }) + * ``` + */ +export const createApolloErrorFormatter = (options?: ErrorFormatterOptions) => { + const { logger } = options ?? {} + + return (formattedError: { message: string; extensions?: Record }, error: unknown) => { + // Get the original error + const originalError = error instanceof GraphQLError ? error.originalError : error + + // Convert to ErrorX + const errorX = ErrorX.isErrorX(originalError) + ? originalError + : ErrorX.from(originalError ?? formattedError) + + // Log the error + if (logger) { + logger(toLogEntry(errorX, { includeStack: true })) + } + + // Return formatted error with ErrorX extensions + return { + message: formattedError.message, + extensions: { + ...formattedError.extensions, + ...toGraphQLExtensions(errorX, options), + }, + } + } +} + +/** + * Apollo Server plugin for error handling. + * + * @example + * ```typescript + * const server = new ApolloServer({ + * typeDefs, + * resolvers, + * plugins: [ + * errorXPlugin({ + * logger: (entry) => myLogger.error(entry) + * }) + * ] + * }) + * ``` + */ +export const errorXPlugin = (options?: { + logger?: (entry: ReturnType, context: { operationName?: string }) => void +}) => ({ + async requestDidStart() { + return { + async didEncounterErrors({ errors, operationName }: { errors: readonly GraphQLError[]; operationName?: string }) { + for (const error of errors) { + const originalError = error.originalError + const errorX = ErrorX.isErrorX(originalError) + ? originalError + : ErrorX.from(originalError ?? error) + + if (options?.logger) { + options.logger( + toLogEntry(errorX, { + includeStack: true, + context: { operationName }, + }), + { operationName } + ) + } + } + }, + } + }, +}) + +// ============================================================================ +// graphql-yoga Integration +// ============================================================================ + +/** + * Create a graphql-yoga mask error function. + * + * @example + * ```typescript + * import { createYoga } from 'graphql-yoga' + * import { createYogaErrorMasker } from './graphql-integration' + * + * const yoga = createYoga({ + * schema, + * maskedErrors: { + * maskError: createYogaErrorMasker({ includeStack: false }) + * } + * }) + * ``` + */ +export const createYogaErrorMasker = (options?: ErrorFormatterOptions) => { + const { maskInternalErrors = true, logger } = options ?? {} + + return (error: unknown, message: string) => { + // Convert to ErrorX + const errorX = ErrorX.isErrorX(error) ? error : ErrorX.from(error) + + // Log + if (logger) { + logger(toLogEntry(errorX, { includeStack: true })) + } + + // Check if it's a user-facing error + const isUserError = errorX.httpStatus !== undefined && errorX.httpStatus < 500 + + if (isUserError || !maskInternalErrors) { + return toGraphQLError(errorX, options) + } + + // Return masked error + return new GraphQLError(message, { + extensions: { + code: 'INTERNAL_SERVER_ERROR', + timestamp: new Date().toISOString(), + fingerprint: generateFingerprint(errorX), + }, + }) + } +} + +// ============================================================================ +// Error Classes for GraphQL +// ============================================================================ + +/** + * GraphQL-specific error codes. + */ +const graphqlPresets = { + UNAUTHENTICATED: { + code: 'UNAUTHENTICATED', + name: 'AuthenticationError', + message: 'You must be logged in to perform this action.', + httpStatus: 401, + }, + FORBIDDEN: { + code: 'FORBIDDEN', + name: 'ForbiddenError', + message: 'You are not authorized to perform this action.', + httpStatus: 403, + }, + NOT_FOUND: { + code: 'NOT_FOUND', + name: 'NotFoundError', + message: 'The requested resource was not found.', + httpStatus: 404, + }, + BAD_USER_INPUT: { + code: 'BAD_USER_INPUT', + name: 'UserInputError', + message: 'Invalid input provided.', + httpStatus: 400, + }, + PERSISTED_QUERY_NOT_FOUND: { + code: 'PERSISTED_QUERY_NOT_FOUND', + name: 'PersistedQueryNotFoundError', + message: 'The persisted query was not found.', + httpStatus: 400, + }, + PERSISTED_QUERY_NOT_SUPPORTED: { + code: 'PERSISTED_QUERY_NOT_SUPPORTED', + name: 'PersistedQueryNotSupportedError', + message: 'Persisted queries are not supported.', + httpStatus: 400, + }, +} as const + +type GraphQLPresetKey = keyof typeof graphqlPresets | (string & {}) + +/** + * GraphQL-specific error class with Apollo-compatible codes. + * + * @example + * ```typescript + * // In resolver + * if (!context.user) { + * throw GraphQLErrorX.create('UNAUTHENTICATED') + * } + * + * const user = await getUser(id) + * if (!user) { + * throw GraphQLErrorX.create('NOT_FOUND', { + * message: 'User not found', + * metadata: { userId: id } + * }) + * } + * ``` + */ +export class GraphQLErrorX extends ErrorX { + static presets = graphqlPresets + static defaultPreset = 'BAD_USER_INPUT' + + static override create( + presetKey?: GraphQLPresetKey, + overrides?: Partial[1]> + ): GraphQLErrorX { + return ErrorX.create.call(GraphQLErrorX, presetKey, overrides) as GraphQLErrorX + } + + /** + * Convert to GraphQLError for throwing. + */ + toGraphQL(options?: ErrorFormatterOptions): GraphQLError { + return toGraphQLError(this, options) + } +} + +// ============================================================================ +// Resolver Helpers +// ============================================================================ + +/** + * Wrap a resolver function with error handling. + * + * @example + * ```typescript + * const resolvers = { + * Query: { + * user: withErrorHandling(async (_, { id }, context) => { + * const user = await context.db.user.findUnique({ where: { id } }) + * if (!user) { + * throw GraphQLErrorX.create('NOT_FOUND', { metadata: { userId: id } }) + * } + * return user + * }) + * } + * } + * ``` + */ +export const withErrorHandling = ( + resolver: (parent: TParent, args: TArgs, context: TContext, info: unknown) => Promise, + options?: ErrorFormatterOptions +) => { + return async (parent: TParent, args: TArgs, context: TContext, info: unknown): Promise => { + try { + return await resolver(parent, args, context, info) + } catch (err) { + // Already a GraphQL error + if (err instanceof GraphQLError) { + throw err + } + + // Convert and throw + throw toGraphQLErrorFromAny(err, options) + } + } +} + +/** + * Create a resolver that requires authentication. + * + * @example + * ```typescript + * const resolvers = { + * Mutation: { + * updateProfile: requireAuth(async (_, { input }, { user }) => { + * return updateUser(user.id, input) + * }) + * } + * } + * ``` + */ +export const requireAuth = ( + resolver: (parent: TParent, args: TArgs, context: TContext, info: unknown) => Promise +) => { + return async (parent: TParent, args: TArgs, context: TContext, info: unknown): Promise => { + if (!context.user) { + throw GraphQLErrorX.create('UNAUTHENTICATED') + } + return resolver(parent, args, context, info) + } +} + +// ============================================================================ +// Client-Side Error Parsing +// ============================================================================ + +/** + * Parse GraphQL errors on the client side. + * + * @example + * ```typescript + * import { useQuery } from '@apollo/client' + * + * const { data, error } = useQuery(GET_USER) + * + * if (error) { + * const parsed = parseGraphQLErrors(error) + * parsed.forEach(e => { + * console.log(e.code, e.message) + * showToast(e.userMessage) + * }) + * } + * ``` + */ +export const parseGraphQLErrors = (error: { graphQLErrors?: readonly GraphQLError[] }) => { + const errors = error.graphQLErrors ?? [] + + return errors.map((e) => { + const extensions = e.extensions as GraphQLErrorExtensions | undefined + + return { + code: extensions?.code ?? 'UNKNOWN_ERROR', + message: e.message, + userMessage: getUserFriendlyMessage(extensions?.code, extensions?.httpStatus), + httpStatus: extensions?.httpStatus, + metadata: extensions?.metadata, + fingerprint: extensions?.fingerprint, + } + }) +} + +/** + * Get user-friendly message for common error codes. + */ +const getUserFriendlyMessage = (code?: string, httpStatus?: number): string => { + const messages: Record = { + UNAUTHENTICATED: 'Please log in to continue.', + FORBIDDEN: 'You don\'t have permission to do that.', + NOT_FOUND: 'The requested item could not be found.', + BAD_USER_INPUT: 'Please check your input and try again.', + INTERNAL_SERVER_ERROR: 'Something went wrong. Please try again later.', + } + + if (code && messages[code]) { + return messages[code] + } + + if (httpStatus && httpStatus >= 500) { + return 'Something went wrong. Please try again later.' + } + + return 'An error occurred. Please try again.' +} + +// ============================================================================ +// Complete Usage Examples +// ============================================================================ + +/** + * ## Apollo Server Setup + * + * ```typescript + * import { ApolloServer } from '@apollo/server' + * import { startStandaloneServer } from '@apollo/server/standalone' + * import { + * createApolloErrorFormatter, + * errorXPlugin, + * GraphQLErrorX, + * withErrorHandling, + * requireAuth + * } from './graphql-integration' + * + * const typeDefs = ` + * type User { + * id: ID! + * email: String! + * name: String! + * } + * + * type Query { + * user(id: ID!): User + * me: User + * } + * + * type Mutation { + * updateProfile(name: String!): User + * } + * ` + * + * const resolvers = { + * Query: { + * user: withErrorHandling(async (_, { id }, { db }) => { + * const user = await db.user.findUnique({ where: { id } }) + * if (!user) { + * throw GraphQLErrorX.create('NOT_FOUND', { + * message: 'User not found', + * metadata: { userId: id } + * }) + * } + * return user + * }), + * + * me: requireAuth(async (_, __, { user, db }) => { + * return db.user.findUnique({ where: { id: user.id } }) + * }) + * }, + * + * Mutation: { + * updateProfile: requireAuth(async (_, { name }, { user, db }) => { + * return db.user.update({ + * where: { id: user.id }, + * data: { name } + * }) + * }) + * } + * } + * + * const server = new ApolloServer({ + * typeDefs, + * resolvers, + * formatError: createApolloErrorFormatter({ + * includeStack: process.env.NODE_ENV !== 'production', + * logger: (entry) => logger.error('[GraphQL]', entry) + * }), + * plugins: [ + * errorXPlugin({ + * logger: (entry, ctx) => + * logger.error(`[${ctx.operationName ?? 'Anonymous'}]`, entry) + * }) + * ] + * }) + * + * await startStandaloneServer(server, { listen: { port: 4000 } }) + * ``` + * + * ## graphql-yoga Setup + * + * ```typescript + * import { createYoga } from 'graphql-yoga' + * import { createServer } from 'node:http' + * import { createYogaErrorMasker, GraphQLErrorX } from './graphql-integration' + * + * const yoga = createYoga({ + * schema, + * maskedErrors: { + * maskError: createYogaErrorMasker({ + * logger: console.error, + * maskInternalErrors: true + * }) + * } + * }) + * + * const server = createServer(yoga) + * server.listen(4000) + * ``` + * + * ## Client Usage with Apollo Client + * + * ```typescript + * import { useQuery, useMutation } from '@apollo/client' + * import { parseGraphQLErrors } from './graphql-integration' + * + * const UserProfile = ({ userId }) => { + * const { data, loading, error } = useQuery(GET_USER, { + * variables: { id: userId } + * }) + * + * if (loading) return + * + * if (error) { + * const errors = parseGraphQLErrors(error) + * return ( + * + * ) + * } + * + * return + * } + * ``` + */ + +export { + ErrorX, + HTTPErrorX, + DBErrorX, + ValidationErrorX, + toLogEntry, + generateFingerprint, +} diff --git a/examples/hono-middleware.ts b/examples/hono-middleware.ts new file mode 100644 index 0000000..eb59da9 --- /dev/null +++ b/examples/hono-middleware.ts @@ -0,0 +1,233 @@ +/** + * Hono.js Error Middleware Example + * + * This example shows how to integrate error-x with Hono.js for + * consistent error handling across your API. + * + * @example + * ```bash + * pnpm add hono @bombillazo/error-x + * ``` + */ + +import { Hono } from 'hono' +import type { Context, ErrorHandler, MiddlewareHandler } from 'hono' +import { + ErrorX, + HTTPErrorX, + ValidationErrorX, + toLogEntry, + type ErrorXSerialized, +} from '@bombillazo/error-x' + +// ============================================================================ +// Types +// ============================================================================ + +type ErrorResponse = { + error: { + code: string + message: string + timestamp: string + requestId?: string + } + // Include full error details in development only + details?: ErrorXSerialized +} + +type ErrorMiddlewareOptions = { + /** Include stack traces in response (default: false in prod) */ + includeDetails?: boolean + /** Custom logger function */ + logger?: (entry: ReturnType) => void + /** Header name for request ID (default: 'x-request-id') */ + requestIdHeader?: string +} + +// ============================================================================ +// Error Middleware +// ============================================================================ + +/** + * Creates an error handling middleware for Hono. + * Converts all errors to ErrorX and returns consistent JSON responses. + */ +export const errorMiddleware = (options: ErrorMiddlewareOptions = {}): ErrorHandler => { + const { + includeDetails = process.env.NODE_ENV !== 'production', + logger = console.error, + requestIdHeader = 'x-request-id', + } = options + + return (err: Error, c: Context) => { + // Convert any error to ErrorX + const error = ErrorX.isErrorX(err) ? err : ErrorX.from(err) + + // Get request ID if present + const requestId = c.req.header(requestIdHeader) + + // Log the error + const logEntry = toLogEntry(error, { + includeStack: true, + includeFull: true, + context: { + requestId, + method: c.req.method, + path: c.req.path, + userAgent: c.req.header('user-agent'), + }, + }) + logger(logEntry) + + // Determine HTTP status + const status = error.httpStatus ?? 500 + + // Build response + const response: ErrorResponse = { + error: { + code: error.code, + message: error.message, + timestamp: new Date(error.timestamp).toISOString(), + ...(requestId && { requestId }), + }, + } + + // Include full error details in development + if (includeDetails) { + response.details = error.toJSON() + } + + return c.json(response, status as 400) + } +} + +/** + * Request ID middleware - adds a unique ID to each request. + */ +export const requestIdMiddleware = (): MiddlewareHandler => { + return async (c, next) => { + const requestId = c.req.header('x-request-id') ?? crypto.randomUUID() + c.set('requestId', requestId) + c.header('x-request-id', requestId) + await next() + } +} + +// ============================================================================ +// Example Application +// ============================================================================ + +const app = new Hono() + +// Add request ID tracking +app.use('*', requestIdMiddleware()) + +// Example routes that throw different error types +app.get('/user/:id', async (c) => { + const id = c.req.param('id') + + // Simulate user not found + if (id === '0') { + throw HTTPErrorX.create(404, { + message: 'User not found', + metadata: { userId: id }, + }) + } + + // Simulate server error + if (id === 'error') { + throw new Error('Database connection lost') + } + + return c.json({ id, name: 'John Doe' }) +}) + +app.post('/user', async (c) => { + const body = await c.req.json() + + // Simulate validation error + if (!body.email) { + throw ValidationErrorX.forField('email', 'Email is required') + } + + return c.json({ id: '123', ...body }, 201) +}) + +// Global error handler - catches all errors +app.onError(errorMiddleware({ + logger: (entry) => { + // In production, use a structured logger + console.error(JSON.stringify(entry, null, 2)) + }, +})) + +// Not found handler +app.notFound((c) => { + const error = HTTPErrorX.create(404, { + message: `Route ${c.req.method} ${c.req.path} not found`, + }) + + return c.json({ + error: { + code: error.code, + message: error.message, + }, + }, 404) +}) + +export default app + +// ============================================================================ +// Usage Notes +// ============================================================================ + +/** + * ## Usage + * + * 1. Import the middleware: + * ```typescript + * import { errorMiddleware, requestIdMiddleware } from './hono-middleware' + * ``` + * + * 2. Add to your Hono app: + * ```typescript + * app.use('*', requestIdMiddleware()) + * app.onError(errorMiddleware()) + * ``` + * + * 3. Throw ErrorX errors in your handlers: + * ```typescript + * throw HTTPErrorX.create(400, { message: 'Invalid input' }) + * throw ValidationErrorX.fromZodError(zodError) + * throw new ErrorX({ code: 'CUSTOM_ERROR', message: 'Something went wrong' }) + * ``` + * + * ## Response Format + * + * All errors return a consistent JSON structure: + * ```json + * { + * "error": { + * "code": "NOT_FOUND", + * "message": "User not found", + * "timestamp": "2024-01-01T00:00:00.000Z", + * "requestId": "abc-123" + * } + * } + * ``` + * + * In development, additional details are included: + * ```json + * { + * "error": { ... }, + * "details": { + * "name": "NotFoundError", + * "code": "NOT_FOUND", + * "message": "User not found", + * "stack": "...", + * "metadata": { "userId": "0" }, + * "chain": [...] + * } + * } + * ``` + */ diff --git a/examples/logging-integration.ts b/examples/logging-integration.ts new file mode 100644 index 0000000..ce318bf --- /dev/null +++ b/examples/logging-integration.ts @@ -0,0 +1,492 @@ +/** + * Logging Library Integration Example + * + * This example shows how to integrate error-x with popular logging libraries + * like pino, winston, and bunyan for structured error logging. + * + * @example + * ```bash + * pnpm add pino @bombillazo/error-x + * # or + * pnpm add winston @bombillazo/error-x + * ``` + */ + +import { + ErrorX, + HTTPErrorX, + DBErrorX, + toLogEntry, + generateFingerprint, + type ErrorLogEntry, +} from '@bombillazo/error-x' + +// ============================================================================ +// Pino Integration +// ============================================================================ + +/** + * Creates a pino logger configuration with error-x serializers. + * + * @example + * ```typescript + * import pino from 'pino' + * import { createPinoConfig } from './logging-integration' + * + * const logger = pino(createPinoConfig()) + * + * // Log an error + * const error = HTTPErrorX.create(404) + * logger.error({ err: error }, 'Request failed') + * // Output: {"level":30,"err":{"code":"NOT_FOUND","fingerprint":"abc123",...},...} + * ``` + */ +export const createPinoConfig = (options?: { + includeStack?: boolean + includeFull?: boolean +}) => { + const { includeStack = true, includeFull = false } = options ?? {} + + return { + serializers: { + // Custom error serializer for ErrorX + err: (err: unknown) => { + if (ErrorX.isErrorX(err)) { + return toLogEntry(err, { includeStack, includeFull }) + } + // Fall back to standard error serialization + if (err instanceof Error) { + return { + type: err.name, + message: err.message, + stack: includeStack ? err.stack : undefined, + } + } + return err + }, + }, + // Recommended pino settings for production + level: process.env.LOG_LEVEL ?? 'info', + formatters: { + level: (label: string) => ({ level: label }), + }, + } +} + +/** + * Pino child logger factory with request context. + * + * @example + * ```typescript + * import pino from 'pino' + * import { createRequestLogger } from './logging-integration' + * + * const baseLogger = pino(createPinoConfig()) + * + * app.use((req, res, next) => { + * req.log = createRequestLogger(baseLogger, { + * requestId: req.headers['x-request-id'], + * method: req.method, + * path: req.path + * }) + * next() + * }) + * + * // Later in handlers: + * req.log.error({ err: error }, 'Database query failed') + * ``` + */ +export const createRequestLogger = TLogger }>( + logger: TLogger, + context: { + requestId?: string + method?: string + path?: string + userId?: string + [key: string]: unknown + } +): TLogger => { + return logger.child({ + req: context, + }) +} + +// ============================================================================ +// Winston Integration +// ============================================================================ + +/** + * Winston format for ErrorX errors. + * + * @example + * ```typescript + * import winston from 'winston' + * import { errorXFormat, createWinstonLogger } from './logging-integration' + * + * const logger = createWinstonLogger() + * + * // Log errors + * logger.error('Request failed', { error: HTTPErrorX.create(500) }) + * ``` + */ +export const errorXFormat = () => { + return { + transform: (info: { error?: unknown; [key: string]: unknown }) => { + if (info.error && ErrorX.isErrorX(info.error)) { + const entry = toLogEntry(info.error, { includeStack: true }) + return { + ...info, + errorCode: entry.errorCode, + errorName: entry.errorName, + fingerprint: entry.fingerprint, + httpStatus: entry.httpStatus, + metadata: entry.metadata, + chainDepth: entry.chainDepth, + rootCause: entry.rootCause, + } + } + return info + }, + } +} + +/** + * Create a Winston logger with ErrorX support. + * + * @example + * ```typescript + * const logger = createWinstonLogger({ + * level: 'debug', + * serviceName: 'api-server' + * }) + * + * try { + * await operation() + * } catch (err) { + * logger.error('Operation failed', { + * error: ErrorX.from(err), + * context: { userId: '123' } + * }) + * } + * ``` + */ +export const createWinstonLogger = (options?: { + level?: string + serviceName?: string + prettyPrint?: boolean +}) => { + // Winston would be imported here, but we show the configuration pattern + const config = { + level: options?.level ?? 'info', + defaultMeta: { + service: options?.serviceName ?? 'app', + }, + format: { + combine: [ + { timestamp: true }, + errorXFormat(), + options?.prettyPrint + ? { prettyPrint: { colorize: true } } + : { json: true }, + ], + }, + transports: [ + { type: 'Console' }, + // Add file transport for production: + // { type: 'File', filename: 'logs/error.log', level: 'error' }, + // { type: 'File', filename: 'logs/combined.log' }, + ], + } + + return config +} + +// ============================================================================ +// Generic Logging Utilities +// ============================================================================ + +/** + * Logger interface that works with any logging library. + */ +export type LoggerInterface = { + error: (message: string, context?: object) => void + warn: (message: string, context?: object) => void + info: (message: string, context?: object) => void + debug: (message: string, context?: object) => void +} + +/** + * Create an error logging helper that wraps any logger. + * + * @example + * ```typescript + * import pino from 'pino' + * const pinoLogger = pino() + * + * const errorLogger = createErrorLogger({ + * error: (msg, ctx) => pinoLogger.error(ctx, msg), + * warn: (msg, ctx) => pinoLogger.warn(ctx, msg), + * info: (msg, ctx) => pinoLogger.info(ctx, msg), + * debug: (msg, ctx) => pinoLogger.debug(ctx, msg), + * }) + * + * // Now use consistent error logging + * errorLogger.logError(error, { requestId: '123' }) + * errorLogger.logError(error, { requestId: '123' }, 'warn') + * ``` + */ +export const createErrorLogger = (logger: LoggerInterface) => { + return { + /** + * Log an ErrorX with full context. + */ + logError: ( + error: ErrorX, + additionalContext?: Record, + level: 'error' | 'warn' | 'info' = 'error' + ) => { + const entry = toLogEntry(error, { + level, + includeStack: true, + context: additionalContext, + }) + + logger[level](entry.message, entry) + }, + + /** + * Log any error, converting to ErrorX first. + */ + logAnyError: ( + err: unknown, + additionalContext?: Record, + level: 'error' | 'warn' | 'info' = 'error' + ) => { + const error = ErrorX.isErrorX(err) ? err : ErrorX.from(err) + const entry = toLogEntry(error, { + level, + includeStack: true, + context: additionalContext, + }) + + logger[level](entry.message, entry) + }, + + /** + * Create a child logger with additional context. + */ + withContext: (context: Record) => { + const wrappedLogger: LoggerInterface = { + error: (msg, ctx) => logger.error(msg, { ...context, ...ctx }), + warn: (msg, ctx) => logger.warn(msg, { ...context, ...ctx }), + info: (msg, ctx) => logger.info(msg, { ...context, ...ctx }), + debug: (msg, ctx) => logger.debug(msg, { ...context, ...ctx }), + } + return createErrorLogger(wrappedLogger) + }, + } +} + +// ============================================================================ +// Error Deduplication +// ============================================================================ + +/** + * Simple in-memory error deduplication tracker. + * Useful for preventing log spam from repeated errors. + * + * @example + * ```typescript + * const dedup = createErrorDeduplicator({ windowMs: 60000, maxPerWindow: 5 }) + * + * // In error handler: + * if (dedup.shouldLog(error)) { + * logger.error('Error occurred', toLogEntry(error)) + * } + * ``` + */ +export const createErrorDeduplicator = (options?: { + /** Time window in milliseconds (default: 60000 = 1 minute) */ + windowMs?: number + /** Max occurrences per fingerprint before suppressing (default: 10) */ + maxPerWindow?: number +}) => { + const { windowMs = 60000, maxPerWindow = 10 } = options ?? {} + + const errorCounts = new Map() + + const cleanup = () => { + const now = Date.now() + for (const [fingerprint, data] of errorCounts) { + if (now - data.firstSeen > windowMs) { + errorCounts.delete(fingerprint) + } + } + } + + return { + /** + * Check if this error should be logged (not deduplicated). + */ + shouldLog: (error: ErrorX): boolean => { + cleanup() + + const fingerprint = generateFingerprint(error) + const now = Date.now() + const existing = errorCounts.get(fingerprint) + + if (!existing) { + errorCounts.set(fingerprint, { count: 1, firstSeen: now }) + return true + } + + if (now - existing.firstSeen > windowMs) { + // Reset window + errorCounts.set(fingerprint, { count: 1, firstSeen: now }) + return true + } + + existing.count++ + + if (existing.count <= maxPerWindow) { + return true + } + + // Log every nth occurrence after threshold + if (existing.count % maxPerWindow === 0) { + return true + } + + return false + }, + + /** + * Get current stats for monitoring. + */ + getStats: () => { + cleanup() + return { + uniqueErrors: errorCounts.size, + entries: Array.from(errorCounts.entries()).map(([fp, data]) => ({ + fingerprint: fp, + count: data.count, + ageMs: Date.now() - data.firstSeen, + })), + } + }, + + /** + * Reset all tracking. + */ + reset: () => { + errorCounts.clear() + }, + } +} + +// ============================================================================ +// Complete Usage Example +// ============================================================================ + +/** + * ## Complete Pino Example + * + * ```typescript + * import pino from 'pino' + * import { ErrorX, HTTPErrorX, DBErrorX, toLogEntry } from '@bombillazo/error-x' + * import { createPinoConfig, createErrorDeduplicator } from './logging-integration' + * + * // Create logger + * const logger = pino(createPinoConfig({ includeStack: true })) + * const dedup = createErrorDeduplicator({ windowMs: 60000 }) + * + * // Express error handler + * app.use((err, req, res, next) => { + * const error = ErrorX.from(err) + * + * // Skip duplicate errors + * if (dedup.shouldLog(error)) { + * const entry = toLogEntry(error, { + * includeStack: true, + * context: { + * requestId: req.headers['x-request-id'], + * method: req.method, + * path: req.path, + * userId: req.user?.id + * } + * }) + * + * logger.error(entry, 'Request error') + * } + * + * res.status(error.httpStatus ?? 500).json({ error: error.code }) + * }) + * ``` + * + * ## Complete Winston Example + * + * ```typescript + * import winston from 'winston' + * import { ErrorX, toLogEntry } from '@bombillazo/error-x' + * import { errorXFormat } from './logging-integration' + * + * const logger = winston.createLogger({ + * level: 'info', + * format: winston.format.combine( + * winston.format.timestamp(), + * winston.format.errors({ stack: true }), + * winston.format(errorXFormat().transform)(), + * winston.format.json() + * ), + * transports: [ + * new winston.transports.Console(), + * new winston.transports.File({ filename: 'error.log', level: 'error' }) + * ] + * }) + * + * // Usage + * try { + * await databaseOperation() + * } catch (err) { + * const error = DBErrorX.create('QUERY_FAILED', { + * cause: err, + * metadata: { query: 'SELECT * FROM users' } + * }) + * + * logger.error('Database query failed', { error }) + * // Output includes: errorCode, fingerprint, chainDepth, rootCause, etc. + * } + * ``` + * + * ## Log Output Example + * + * ```json + * { + * "level": "error", + * "timestamp": "2024-01-01T12:00:00.000Z", + * "message": "Database connection timed out.", + * "fingerprint": "a1b2c3d4", + * "errorName": "DatabaseError", + * "errorCode": "DB_CONNECTION_TIMEOUT", + * "httpStatus": 500, + * "chainDepth": 2, + * "rootCause": { + * "name": "Error", + * "message": "ETIMEDOUT", + * "code": "ERROR" + * }, + * "metadata": { + * "host": "db.example.com", + * "port": 5432, + * "timeout": 5000 + * }, + * "req": { + * "requestId": "abc-123", + * "method": "GET", + * "path": "/api/users" + * } + * } + * ``` + */ + +export { ErrorX, HTTPErrorX, DBErrorX, toLogEntry, generateFingerprint } diff --git a/examples/react-error-boundary.tsx b/examples/react-error-boundary.tsx new file mode 100644 index 0000000..0084710 --- /dev/null +++ b/examples/react-error-boundary.tsx @@ -0,0 +1,538 @@ +/** + * React Error Boundary Integration Example + * + * This example shows how to integrate error-x with React error boundaries + * for consistent error handling in React applications. + * + * @example + * ```bash + * pnpm add react react-dom @bombillazo/error-x + * pnpm add -D @types/react @types/react-dom + * ``` + */ + +import React, { Component, createContext, useContext, useCallback, useState } from 'react' +import type { ReactNode, ErrorInfo } from 'react' +import { + ErrorX, + HTTPErrorX, + httpErrorUiMessages, + toLogEntry, + generateFingerprint, + type ErrorXSerialized, +} from '@bombillazo/error-x' + +// ============================================================================ +// Types +// ============================================================================ + +type ErrorBoundaryProps = { + children: ReactNode + /** Custom fallback UI component */ + fallback?: React.ComponentType + /** Called when an error is caught */ + onError?: (error: ErrorX, errorInfo: ErrorInfo) => void + /** Called when user clicks retry */ + onRetry?: () => void + /** Show detailed error info (default: false in production) */ + showDetails?: boolean +} + +type ErrorBoundaryState = { + error: ErrorX | null + errorInfo: ErrorInfo | null +} + +type ErrorFallbackProps = { + error: ErrorX + errorInfo: ErrorInfo | null + resetError: () => void + showDetails: boolean +} + +type ErrorContextValue = { + /** Report a caught error to the error boundary */ + reportError: (error: unknown) => void + /** Clear the current error state */ + clearError: () => void + /** Current error if any */ + error: ErrorX | null +} + +// ============================================================================ +// Error Context +// ============================================================================ + +const ErrorContext = createContext(null) + +/** + * Hook to access error boundary context. + * Allows components to report errors imperatively. + * + * @example + * ```tsx + * const MyComponent = () => { + * const { reportError } = useErrorBoundary() + * + * const handleClick = async () => { + * try { + * await riskyOperation() + * } catch (err) { + * reportError(err) + * } + * } + * + * return + * } + * ``` + */ +export const useErrorBoundary = (): ErrorContextValue => { + const context = useContext(ErrorContext) + if (!context) { + throw new Error('useErrorBoundary must be used within an ErrorBoundaryProvider') + } + return context +} + +// ============================================================================ +// Error Boundary Component +// ============================================================================ + +/** + * React Error Boundary that converts all errors to ErrorX. + * + * @example + * ```tsx + * { + * // Send to error tracking service + * trackError(error, info) + * }} + * fallback={CustomErrorUI} + * > + * + * + * ``` + */ +export class ErrorBoundary extends Component { + state: ErrorBoundaryState = { + error: null, + errorInfo: null, + } + + static getDerivedStateFromError(err: unknown): Partial { + // Convert to ErrorX for consistent handling + const error = ErrorX.isErrorX(err) + ? err + : ErrorX.from(err, { name: 'ReactError', code: 'REACT_ERROR' }) + + return { error } + } + + componentDidCatch(err: unknown, errorInfo: ErrorInfo): void { + const error = this.state.error ?? ErrorX.from(err) + + // Update state with error info + this.setState({ errorInfo }) + + // Call custom error handler + if (this.props.onError) { + this.props.onError(error, errorInfo) + } + + // Log error with context + const logEntry = toLogEntry(error, { + includeStack: true, + context: { + componentStack: errorInfo.componentStack, + fingerprint: generateFingerprint(error), + }, + }) + console.error('[ErrorBoundary]', logEntry) + } + + resetError = (): void => { + this.setState({ error: null, errorInfo: null }) + this.props.onRetry?.() + } + + render(): ReactNode { + const { error, errorInfo } = this.state + const { children, fallback: Fallback, showDetails = process.env.NODE_ENV !== 'production' } = this.props + + if (error) { + if (Fallback) { + return ( + + ) + } + + return ( + + ) + } + + return ( + { + const error = ErrorX.from(err) + this.setState({ error, errorInfo: null }) + }, + clearError: this.resetError, + error: this.state.error, + }} + > + {children} + + ) + } +} + +// ============================================================================ +// Default Fallback UI +// ============================================================================ + +/** + * Default error fallback component with sensible styling. + */ +const DefaultErrorFallback: React.FC = ({ + error, + errorInfo, + resetError, + showDetails, +}) => { + const [showStack, setShowStack] = useState(false) + + // Get user-friendly message if it's an HTTP error + const userMessage = error.httpStatus + ? httpErrorUiMessages[error.httpStatus as keyof typeof httpErrorUiMessages] + : undefined + + return ( +
+
+

Something went wrong

+ +

+ {userMessage ?? error.message} +

+ + {error.code && ( +

+ Error Code: {error.code} +

+ )} + +
+ + +
+ + {showDetails && ( +
+ + + {showStack && ( +
+                Error: {error.name}: {error.message}
+                {'\n\n'}
+                Code: {error.code}
+                {'\n'}
+                Timestamp: {new Date(error.timestamp).toISOString()}
+                {error.httpStatus && (
+                  <>
+                    {'\n'}
+                    HTTP Status: {error.httpStatus}
+                  
+                )}
+                {error.metadata && (
+                  <>
+                    {'\n\n'}
+                    Metadata:
+                    {'\n'}
+                    {JSON.stringify(error.metadata, null, 2)}
+                  
+                )}
+                {error.stack && (
+                  <>
+                    {'\n\n'}
+                    Stack Trace:
+                    {'\n'}
+                    {error.stack}
+                  
+                )}
+                {errorInfo?.componentStack && (
+                  <>
+                    {'\n\n'}
+                    Component Stack:
+                    {errorInfo.componentStack}
+                  
+                )}
+              
+ )} +
+ )} +
+
+ ) +} + +// ============================================================================ +// Styles +// ============================================================================ + +const styles: Record = { + container: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: '100vh', + padding: '2rem', + backgroundColor: '#f8f9fa', + fontFamily: 'system-ui, -apple-system, sans-serif', + }, + content: { + maxWidth: '600px', + padding: '2rem', + backgroundColor: '#fff', + borderRadius: '8px', + boxShadow: '0 2px 10px rgba(0, 0, 0, 0.1)', + textAlign: 'center', + }, + title: { + margin: '0 0 1rem', + color: '#dc3545', + fontSize: '1.5rem', + }, + message: { + margin: '0 0 1rem', + color: '#333', + fontSize: '1rem', + lineHeight: '1.5', + }, + code: { + margin: '0 0 1.5rem', + color: '#666', + fontSize: '0.875rem', + }, + actions: { + display: 'flex', + gap: '1rem', + justifyContent: 'center', + marginBottom: '1.5rem', + }, + primaryButton: { + padding: '0.75rem 1.5rem', + backgroundColor: '#007bff', + color: '#fff', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '1rem', + }, + secondaryButton: { + padding: '0.75rem 1.5rem', + backgroundColor: '#fff', + color: '#333', + border: '1px solid #ccc', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '1rem', + }, + details: { + borderTop: '1px solid #eee', + paddingTop: '1rem', + textAlign: 'left', + }, + toggleButton: { + padding: '0.5rem 1rem', + backgroundColor: 'transparent', + color: '#666', + border: '1px solid #ddd', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '0.875rem', + }, + stack: { + marginTop: '1rem', + padding: '1rem', + backgroundColor: '#f8f9fa', + borderRadius: '4px', + fontSize: '0.75rem', + overflow: 'auto', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + textAlign: 'left', + }, +} + +// ============================================================================ +// Utility Hooks +// ============================================================================ + +/** + * Hook for handling async operations with automatic error reporting. + * + * @example + * ```tsx + * const MyComponent = () => { + * const { execute, loading, error } = useAsyncError() + * + * const handleSubmit = () => { + * execute(async () => { + * await submitForm(data) + * }) + * } + * + * return ( + * + * ) + * } + * ``` + */ +export const useAsyncError = () => { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const execute = useCallback(async (fn: () => Promise): Promise => { + setLoading(true) + setError(null) + + try { + const result = await fn() + return result + } catch (err) { + const errorX = ErrorX.from(err) + setError(errorX) + return undefined + } finally { + setLoading(false) + } + }, []) + + const clearError = useCallback(() => setError(null), []) + + return { execute, loading, error, clearError } +} + +// ============================================================================ +// Usage Examples +// ============================================================================ + +/** + * ## Complete Usage Example + * + * ```tsx + * import { ErrorBoundary, useErrorBoundary, useAsyncError } from './react-error-boundary' + * import { ErrorX, HTTPErrorX } from '@bombillazo/error-x' + * + * // Custom fallback component + * const CustomErrorFallback = ({ error, resetError, showDetails }) => ( + *
+ *

Oops!

+ *

{error.message}

+ * + *
+ * ) + * + * // App with error boundary + * const App = () => ( + * { + * // Send to Sentry, LogRocket, etc. + * errorTracker.capture(error, { + * extra: { + * componentStack: info.componentStack, + * errorCode: error.code, + * fingerprint: generateFingerprint(error) + * } + * }) + * }} + * > + * + * + * ) + * + * // Component using error boundary hook + * const DataFetcher = () => { + * const { reportError } = useErrorBoundary() + * const { execute, loading, error } = useAsyncError() + * + * useEffect(() => { + * execute(async () => { + * const response = await fetch('/api/data') + * if (!response.ok) { + * throw HTTPErrorX.create(response.status) + * } + * return response.json() + * }) + * }, []) + * + * // For critical errors, report to boundary (shows full-page error) + * const handleCriticalOperation = async () => { + * try { + * await criticalOperation() + * } catch (err) { + * reportError(err) // Shows error boundary fallback + * } + * } + * + * // For non-critical errors, handle locally + * if (error) { + * return + * } + * + * if (loading) return + * + * return + * } + * ``` + * + * ## With Error Tracking Services + * + * ```tsx + * import * as Sentry from '@sentry/react' + * + * { + * Sentry.withScope((scope) => { + * scope.setExtras({ + * componentStack: errorInfo.componentStack, + * errorCode: error.code, + * errorTimestamp: error.timestamp, + * metadata: error.metadata, + * chain: error.chain.map(e => e.code) + * }) + * Sentry.captureException(error) + * }) + * }} + * > + * + * + * ``` + */ + +export { ErrorX, HTTPErrorX, generateFingerprint } diff --git a/examples/trpc-integration.ts b/examples/trpc-integration.ts new file mode 100644 index 0000000..f84dfec --- /dev/null +++ b/examples/trpc-integration.ts @@ -0,0 +1,545 @@ +/** + * tRPC Error Integration Example + * + * This example shows how to integrate error-x with tRPC for + * type-safe API error handling. + * + * @example + * ```bash + * pnpm add @trpc/server @trpc/client @bombillazo/error-x + * ``` + */ + +import { + ErrorX, + HTTPErrorX, + DBErrorX, + ValidationErrorX, + httpErrorUiMessages, + toLogEntry, + type ErrorXSerialized, +} from '@bombillazo/error-x' +import { TRPCError } from '@trpc/server' +import type { TRPC_ERROR_CODE_KEY } from '@trpc/server/rpc' + +// ============================================================================ +// Type Mappings +// ============================================================================ + +/** + * Map HTTP status codes to tRPC error codes. + */ +const httpStatusToTrpcCode: Record = { + 400: 'BAD_REQUEST', + 401: 'UNAUTHORIZED', + 403: 'FORBIDDEN', + 404: 'NOT_FOUND', + 405: 'METHOD_NOT_SUPPORTED', + 408: 'TIMEOUT', + 409: 'CONFLICT', + 412: 'PRECONDITION_FAILED', + 413: 'PAYLOAD_TOO_LARGE', + 422: 'UNPROCESSABLE_CONTENT', + 429: 'TOO_MANY_REQUESTS', + 499: 'CLIENT_CLOSED_REQUEST', + 500: 'INTERNAL_SERVER_ERROR', + 501: 'NOT_IMPLEMENTED', + 502: 'BAD_GATEWAY', + 503: 'SERVICE_UNAVAILABLE', + 504: 'GATEWAY_TIMEOUT', +} + +/** + * Map ErrorX codes to tRPC error codes. + */ +const errorCodeToTrpcCode: Record = { + // Validation errors + VALIDATION_FAILED: 'BAD_REQUEST', + VALIDATION_INVALID: 'BAD_REQUEST', + + // Auth errors + AUTH_FAILED: 'UNAUTHORIZED', + AUTH_EXPIRED: 'UNAUTHORIZED', + AUTH_INVALID: 'UNAUTHORIZED', + FORBIDDEN: 'FORBIDDEN', + + // Not found + NOT_FOUND: 'NOT_FOUND', + USER_NOT_FOUND: 'NOT_FOUND', + + // Database errors + DB_CONNECTION_FAILED: 'INTERNAL_SERVER_ERROR', + DB_QUERY_FAILED: 'INTERNAL_SERVER_ERROR', + DB_UNIQUE_VIOLATION: 'CONFLICT', + DB_NOT_FOUND: 'NOT_FOUND', +} + +// ============================================================================ +// Conversion Utilities +// ============================================================================ + +/** + * Convert an ErrorX to a TRPCError. + * + * @example + * ```typescript + * throw toTRPCError(HTTPErrorX.create(404, { message: 'User not found' })) + * ``` + */ +export const toTRPCError = (error: ErrorX): TRPCError => { + // Determine tRPC code from HTTP status or error code + let code: TRPC_ERROR_CODE_KEY = 'INTERNAL_SERVER_ERROR' + + if (error.httpStatus && httpStatusToTrpcCode[error.httpStatus]) { + code = httpStatusToTrpcCode[error.httpStatus] + } else if (errorCodeToTrpcCode[error.code]) { + code = errorCodeToTrpcCode[error.code] + } + + return new TRPCError({ + code, + message: error.message, + cause: error, + }) +} + +/** + * Convert a TRPCError back to an ErrorX. + * + * @example + * ```typescript + * // In error formatter + * const errorX = fromTRPCError(error) + * console.log(errorX.code, errorX.metadata) + * ``` + */ +export const fromTRPCError = (trpcError: TRPCError): ErrorX => { + // Check if cause is already an ErrorX + if (ErrorX.isErrorX(trpcError.cause)) { + return trpcError.cause + } + + // Map tRPC code to HTTP status + const httpStatusMap: Record = { + PARSE_ERROR: 400, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + METHOD_NOT_SUPPORTED: 405, + TIMEOUT: 408, + CONFLICT: 409, + PRECONDITION_FAILED: 412, + PAYLOAD_TOO_LARGE: 413, + UNPROCESSABLE_CONTENT: 422, + TOO_MANY_REQUESTS: 429, + CLIENT_CLOSED_REQUEST: 499, + INTERNAL_SERVER_ERROR: 500, + NOT_IMPLEMENTED: 501, + BAD_GATEWAY: 502, + SERVICE_UNAVAILABLE: 503, + GATEWAY_TIMEOUT: 504, + } + + return ErrorX.from(trpcError, { + code: trpcError.code, + httpStatus: httpStatusMap[trpcError.code] ?? 500, + name: 'TRPCError', + }) +} + +// ============================================================================ +// tRPC Error Formatter +// ============================================================================ + +/** + * Custom error shape for tRPC responses. + */ +type ErrorXTRPCShape = { + code: string + message: string + httpStatus?: number + timestamp: number + metadata?: Record + // Only included in development + stack?: string + chain?: Array<{ code: string; message: string }> +} + +/** + * Create an error formatter for tRPC that uses error-x. + * + * @example + * ```typescript + * import { initTRPC } from '@trpc/server' + * import { createErrorFormatter } from './trpc-integration' + * + * const t = initTRPC.create({ + * errorFormatter: createErrorFormatter({ includeStack: false }) + * }) + * ``` + */ +export const createErrorFormatter = (options?: { + /** Include stack trace in response (default: false in production) */ + includeStack?: boolean + /** Include error chain in response (default: false) */ + includeChain?: boolean + /** Logger for error tracking */ + logger?: (entry: ReturnType) => void +}) => { + const { + includeStack = process.env.NODE_ENV !== 'production', + includeChain = false, + logger, + } = options ?? {} + + return ({ shape, error }: { shape: { message: string; code: string }; error: TRPCError }) => { + const errorX = fromTRPCError(error) + + // Log the error + if (logger) { + logger(toLogEntry(errorX, { includeStack: true, includeFull: true })) + } + + const customShape: ErrorXTRPCShape = { + code: errorX.code, + message: errorX.message, + httpStatus: errorX.httpStatus, + timestamp: errorX.timestamp, + } + + if (errorX.metadata && Object.keys(errorX.metadata).length > 0) { + customShape.metadata = errorX.metadata + } + + if (includeStack && errorX.stack) { + customShape.stack = errorX.stack + } + + if (includeChain && errorX.chain.length > 1) { + customShape.chain = errorX.chain.map((e) => ({ + code: e.code, + message: e.message, + })) + } + + return { + ...shape, + data: customShape, + } + } +} + +// ============================================================================ +// tRPC Middleware +// ============================================================================ + +/** + * Create error handling middleware for tRPC. + * Converts all errors to ErrorX before throwing as TRPCError. + * + * @example + * ```typescript + * const t = initTRPC.create() + * + * const errorMiddleware = t.middleware(createErrorMiddleware()) + * + * export const publicProcedure = t.procedure.use(errorMiddleware) + * ``` + */ +export const createErrorMiddleware = (options?: { + /** Logger for error tracking */ + logger?: (error: ErrorX, path: string) => void +}) => { + const { logger } = options ?? {} + + return async ({ + next, + path, + }: { + next: () => Promise<{ ok: true; data: unknown } | { ok: false; error: TRPCError }> + path: string + ctx: TContext + input: TInput + }) => { + try { + return await next() + } catch (err) { + // Convert to ErrorX + const errorX = ErrorX.isErrorX(err) ? err : ErrorX.from(err) + + // Add path context + const enrichedError = errorX.withMetadata({ trpcPath: path }) + + // Log + if (logger) { + logger(enrichedError, path) + } + + // Throw as TRPCError + throw toTRPCError(enrichedError) + } + } +} + +// ============================================================================ +// Client-Side Error Handling +// ============================================================================ + +/** + * Parse tRPC error response on client to get ErrorX-like shape. + * + * @example + * ```typescript + * const trpc = createTRPCProxyClient({ + * links: [httpBatchLink({ url: '/trpc' })] + * }) + * + * try { + * await trpc.user.get.query({ id: '123' }) + * } catch (err) { + * const errorInfo = parseClientError(err) + * console.log(errorInfo.code, errorInfo.message) + * showToast(errorInfo.userMessage) + * } + * ``` + */ +export const parseClientError = (err: unknown): { + code: string + message: string + userMessage: string + httpStatus?: number + metadata?: Record +} => { + // Check if it's a tRPC error with our custom shape + if ( + err && + typeof err === 'object' && + 'data' in err && + err.data && + typeof err.data === 'object' && + 'code' in err.data + ) { + const data = err.data as ErrorXTRPCShape + return { + code: data.code, + message: data.message, + userMessage: data.httpStatus + ? httpErrorUiMessages[data.httpStatus as keyof typeof httpErrorUiMessages] ?? data.message + : data.message, + httpStatus: data.httpStatus, + metadata: data.metadata, + } + } + + // Fallback for standard tRPC errors + if (err instanceof Error) { + return { + code: 'UNKNOWN_ERROR', + message: err.message, + userMessage: 'An unexpected error occurred. Please try again.', + } + } + + return { + code: 'UNKNOWN_ERROR', + message: 'An unknown error occurred', + userMessage: 'An unexpected error occurred. Please try again.', + } +} + +// ============================================================================ +// React Query Integration +// ============================================================================ + +/** + * Error handler for @tanstack/react-query with tRPC. + * + * @example + * ```typescript + * import { QueryClient } from '@tanstack/react-query' + * import { createQueryErrorHandler } from './trpc-integration' + * + * const queryClient = new QueryClient({ + * defaultOptions: { + * queries: { + * retry: (failureCount, error) => { + * const { shouldRetry } = createQueryErrorHandler()(error) + * return shouldRetry && failureCount < 3 + * } + * }, + * mutations: { + * onError: createQueryErrorHandler() + * } + * } + * }) + * ``` + */ +export const createQueryErrorHandler = (options?: { + /** Callback when error occurs */ + onError?: (errorInfo: ReturnType) => void + /** Toast/notification function */ + showNotification?: (message: string, type: 'error' | 'warning') => void +}) => { + const { onError, showNotification } = options ?? {} + + return (err: unknown) => { + const errorInfo = parseClientError(err) + + // Determine if error is retryable + const nonRetryableCodes = [ + 'UNAUTHORIZED', + 'FORBIDDEN', + 'NOT_FOUND', + 'BAD_REQUEST', + 'UNPROCESSABLE_CONTENT', + ] + const shouldRetry = !nonRetryableCodes.includes(errorInfo.code) + + // Call custom handler + if (onError) { + onError(errorInfo) + } + + // Show notification + if (showNotification) { + const type = errorInfo.httpStatus && errorInfo.httpStatus < 500 ? 'warning' : 'error' + showNotification(errorInfo.userMessage, type) + } + + return { errorInfo, shouldRetry } + } +} + +// ============================================================================ +// Complete Usage Example +// ============================================================================ + +/** + * ## Complete Server Setup + * + * ```typescript + * // server/trpc.ts + * import { initTRPC } from '@trpc/server' + * import { createErrorFormatter, createErrorMiddleware, toTRPCError } from './trpc-integration' + * import { ErrorX, HTTPErrorX, ValidationErrorX } from '@bombillazo/error-x' + * import { z } from 'zod' + * + * const t = initTRPC.create({ + * errorFormatter: createErrorFormatter({ + * logger: (entry) => console.error('[tRPC Error]', entry) + * }) + * }) + * + * const errorHandling = t.middleware(createErrorMiddleware()) + * + * const publicProcedure = t.procedure.use(errorHandling) + * + * export const appRouter = t.router({ + * user: t.router({ + * get: publicProcedure + * .input(z.object({ id: z.string() })) + * .query(async ({ input }) => { + * const user = await db.user.findUnique({ where: { id: input.id } }) + * + * if (!user) { + * throw HTTPErrorX.create(404, { + * message: 'User not found', + * metadata: { userId: input.id } + * }) + * } + * + * return user + * }), + * + * create: publicProcedure + * .input(z.object({ + * email: z.string().email(), + * name: z.string().min(2) + * })) + * .mutation(async ({ input }) => { + * try { + * return await db.user.create({ data: input }) + * } catch (err) { + * // Handle unique constraint violation + * if (isUniqueViolation(err)) { + * throw HTTPErrorX.create(409, { + * message: 'Email already exists', + * cause: err, + * metadata: { email: input.email } + * }) + * } + * throw err + * } + * }) + * }) + * }) + * + * export type AppRouter = typeof appRouter + * ``` + * + * ## Complete Client Setup + * + * ```typescript + * // client/trpc.ts + * import { createTRPCReact } from '@trpc/react-query' + * import { QueryClient } from '@tanstack/react-query' + * import { httpBatchLink } from '@trpc/client' + * import { createQueryErrorHandler, parseClientError } from './trpc-integration' + * import type { AppRouter } from '../server/trpc' + * + * export const trpc = createTRPCReact() + * + * const errorHandler = createQueryErrorHandler({ + * showNotification: (message, type) => toast[type](message) + * }) + * + * export const queryClient = new QueryClient({ + * defaultOptions: { + * queries: { + * retry: (failureCount, error) => { + * const { shouldRetry } = errorHandler(error) + * return shouldRetry && failureCount < 3 + * } + * }, + * mutations: { + * onError: (error) => errorHandler(error) + * } + * } + * }) + * + * export const trpcClient = trpc.createClient({ + * links: [httpBatchLink({ url: '/api/trpc' })] + * }) + * ``` + * + * ## Client Component Usage + * + * ```tsx + * const UserProfile = ({ userId }) => { + * const { data, error, isLoading } = trpc.user.get.useQuery({ id: userId }) + * + * if (isLoading) return + * + * if (error) { + * const errorInfo = parseClientError(error) + * return ( + * + * ) + * } + * + * return + * } + * ``` + */ + +export { + ErrorX, + HTTPErrorX, + DBErrorX, + ValidationErrorX, + toLogEntry, +} diff --git a/examples/zod-integration.ts b/examples/zod-integration.ts new file mode 100644 index 0000000..6080b8b --- /dev/null +++ b/examples/zod-integration.ts @@ -0,0 +1,578 @@ +/** + * Zod Integration Example + * + * This example shows comprehensive Zod integration patterns with error-x. + * The library already includes ValidationErrorX with built-in Zod support, + * but this example demonstrates advanced usage patterns. + * + * @example + * ```bash + * pnpm add zod @bombillazo/error-x + * ``` + */ + +import { z, ZodError, ZodIssue } from 'zod' +import { + ErrorX, + ValidationErrorX, + AggregateErrorX, + type ErrorXOptions, +} from '@bombillazo/error-x' + +// ============================================================================ +// Basic Usage (Built-in Support) +// ============================================================================ + +/** + * The simplest way to use Zod with error-x is through ValidationErrorX.fromZodError(). + * + * @example + * ```typescript + * import { z } from 'zod' + * import { ValidationErrorX } from '@bombillazo/error-x' + * + * const userSchema = z.object({ + * email: z.string().email(), + * age: z.number().min(18) + * }) + * + * try { + * userSchema.parse({ email: 'invalid', age: 15 }) + * } catch (err) { + * if (err instanceof z.ZodError) { + * throw ValidationErrorX.fromZodError(err) + * } + * throw err + * } + * ``` + */ + +// ============================================================================ +// Advanced: Safe Parse Wrapper +// ============================================================================ + +/** + * Wrapper for Zod's safeParse that returns ErrorX on failure. + * + * @example + * ```typescript + * const result = safeValidate(userSchema, { email: 'test@example.com', age: 25 }) + * if (result.success) { + * console.log(result.data) + * } else { + * console.log(result.error.code) // VALIDATION_INVALID_STRING or similar + * } + * ``` + */ +export const safeValidate = ( + schema: z.ZodSchema, + data: unknown +): { success: true; data: T } | { success: false; error: ValidationErrorX } => { + const result = schema.safeParse(data) + + if (result.success) { + return { success: true, data: result.data } + } + + return { + success: false, + error: ValidationErrorX.fromZodError(result.error), + } +} + +/** + * Async version for schemas with async refinements. + */ +export const safeValidateAsync = async ( + schema: z.ZodSchema, + data: unknown +): Promise<{ success: true; data: T } | { success: false; error: ValidationErrorX }> => { + const result = await schema.safeParseAsync(data) + + if (result.success) { + return { success: true, data: result.data } + } + + return { + success: false, + error: ValidationErrorX.fromZodError(result.error), + } +} + +// ============================================================================ +// Aggregate Validation Errors +// ============================================================================ + +/** + * Collect all validation errors instead of failing on the first one. + * + * @example + * ```typescript + * const result = validateAll(userSchema, { email: 'invalid', age: -5 }) + * if (!result.success) { + * console.log(result.errors.length) // 2 errors + * result.errors.forEach(e => console.log(e.metadata?.field, e.message)) + * } + * ``` + */ +export const validateAll = ( + schema: z.ZodSchema, + data: unknown +): { success: true; data: T } | { success: false; errors: ValidationErrorX[]; aggregate: AggregateErrorX } => { + const result = schema.safeParse(data) + + if (result.success) { + return { success: true, data: result.data } + } + + // Convert each Zod issue to a ValidationErrorX + const errors = result.error.issues.map((issue) => + ValidationErrorX.forField( + issue.path.join('.'), + issue.message, + { + code: `VALIDATION_${issue.code.toUpperCase()}`, + metadata: { + zodCode: issue.code, + path: issue.path, + ...(issue.code === 'invalid_type' && { + expected: (issue as z.ZodInvalidTypeIssue).expected, + received: (issue as z.ZodInvalidTypeIssue).received, + }), + }, + } + ) + ) + + const aggregate = ErrorX.aggregate(errors, { + message: `Validation failed with ${errors.length} error${errors.length > 1 ? 's' : ''}`, + code: 'VALIDATION_FAILED', + httpStatus: 400, + }) + + return { success: false, errors, aggregate } +} + +// ============================================================================ +// Form Validation Helper +// ============================================================================ + +/** + * Format validation errors for form display. + */ +type FormErrors = Record + +/** + * Convert Zod errors to a form-friendly format. + * + * @example + * ```typescript + * const formSchema = z.object({ + * username: z.string().min(3).max(20), + * email: z.string().email(), + * password: z.string().min(8), + * confirmPassword: z.string() + * }).refine(data => data.password === data.confirmPassword, { + * message: 'Passwords do not match', + * path: ['confirmPassword'] + * }) + * + * const { errors, fieldErrors } = validateForm(formSchema, formData) + * // fieldErrors: { email: ['Invalid email'], confirmPassword: ['Passwords do not match'] } + * ``` + */ +export const validateForm = ( + schema: z.ZodSchema, + data: unknown +): { + success: boolean + data?: T + errors: ValidationErrorX[] + fieldErrors: FormErrors + firstError?: ValidationErrorX +} => { + const result = schema.safeParse(data) + + if (result.success) { + return { + success: true, + data: result.data, + errors: [], + fieldErrors: {}, + } + } + + const errors: ValidationErrorX[] = [] + const fieldErrors: FormErrors = {} + + for (const issue of result.error.issues) { + const fieldPath = issue.path.join('.') || '_root' + + // Add to field errors + if (!fieldErrors[fieldPath]) { + fieldErrors[fieldPath] = [] + } + fieldErrors[fieldPath].push(issue.message) + + // Create ValidationErrorX + errors.push( + ValidationErrorX.forField(fieldPath, issue.message, { + metadata: { zodCode: issue.code, path: issue.path }, + }) + ) + } + + return { + success: false, + errors, + fieldErrors, + firstError: errors[0], + } +} + +// ============================================================================ +// Schema Builder with Error Customization +// ============================================================================ + +/** + * Create a Zod schema with custom error messages using error-x codes. + * + * @example + * ```typescript + * const userSchema = createSchema({ + * email: { + * schema: z.string().email(), + * errorCode: 'INVALID_EMAIL', + * message: 'Please enter a valid email address' + * }, + * age: { + * schema: z.number().min(18), + * errorCode: 'UNDERAGE', + * message: 'You must be at least 18 years old' + * } + * }) + * ``` + */ +type SchemaFieldConfig = { + schema: z.ZodSchema + errorCode?: string + message?: string +} + +export const createSchema = >>( + fields: T +): z.ZodObject<{ [K in keyof T]: T[K]['schema'] }> => { + const shape: Record = {} + + for (const [key, config] of Object.entries(fields)) { + shape[key] = config.schema + } + + return z.object(shape) as z.ZodObject<{ [K in keyof T]: T[K]['schema'] }> +} + +/** + * Validate with custom error codes. + */ +export const validateWithCustomErrors = >>( + fields: T, + data: unknown +): { + success: boolean + data?: z.infer>> + errors: ValidationErrorX[] +} => { + const schema = createSchema(fields) + const result = schema.safeParse(data) + + if (result.success) { + return { success: true, data: result.data, errors: [] } + } + + const errors: ValidationErrorX[] = [] + + for (const issue of result.error.issues) { + const fieldName = issue.path[0] as string + const fieldConfig = fields[fieldName] + + errors.push( + ValidationErrorX.forField(fieldName, fieldConfig?.message ?? issue.message, { + code: fieldConfig?.errorCode, + metadata: { zodCode: issue.code, path: issue.path }, + }) + ) + } + + return { success: false, errors } +} + +// ============================================================================ +// API Request Validation +// ============================================================================ + +/** + * Validate API request body with detailed error response. + * + * @example + * ```typescript + * // Express/Hono route handler + * app.post('/api/users', async (c) => { + * const validation = validateRequest(createUserSchema, await c.req.json()) + * + * if (!validation.success) { + * return c.json(validation.errorResponse, 400) + * } + * + * const user = await createUser(validation.data) + * return c.json(user, 201) + * }) + * ``` + */ +export const validateRequest = ( + schema: z.ZodSchema, + data: unknown +): { + success: true + data: T +} | { + success: false + error: ValidationErrorX + errorResponse: { + success: false + error: { + code: string + message: string + fields: Array<{ + field: string + message: string + code: string + }> + } + } +} => { + const result = schema.safeParse(data) + + if (result.success) { + return { success: true, data: result.data } + } + + const error = ValidationErrorX.fromZodError(result.error) + + const errorResponse = { + success: false as const, + error: { + code: error.code, + message: 'Validation failed', + fields: result.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message, + code: `VALIDATION_${issue.code.toUpperCase()}`, + })), + }, + } + + return { success: false, error, errorResponse } +} + +// ============================================================================ +// Zod Extensions +// ============================================================================ + +/** + * Extend Zod with error-x integration. + * + * @example + * ```typescript + * const schema = z.string().email().errorX({ code: 'INVALID_EMAIL' }) + * + * try { + * schema.parse('invalid') + * } catch (err) { + * // err is already a ValidationErrorX with code 'INVALID_EMAIL' + * } + * ``` + */ +export const extendZod = () => { + // This is a demonstration of the pattern - in practice you might use + // Zod's built-in error customization or wrap at the validation boundary + + const originalParse = z.ZodSchema.prototype.parse + + z.ZodSchema.prototype.parse = function (data: unknown, params?: Partial) { + try { + return originalParse.call(this, data, params) + } catch (err) { + if (err instanceof ZodError) { + throw ValidationErrorX.fromZodError(err) + } + throw err + } + } +} + +// ============================================================================ +// Complete Usage Examples +// ============================================================================ + +/** + * ## Basic Form Validation + * + * ```typescript + * import { z } from 'zod' + * import { ValidationErrorX } from '@bombillazo/error-x' + * + * const loginSchema = z.object({ + * email: z.string().email('Invalid email address'), + * password: z.string().min(8, 'Password must be at least 8 characters') + * }) + * + * // In your form handler + * const handleSubmit = (formData: FormData) => { + * const data = Object.fromEntries(formData) + * + * try { + * const validated = loginSchema.parse(data) + * return login(validated.email, validated.password) + * } catch (err) { + * if (err instanceof z.ZodError) { + * const error = ValidationErrorX.fromZodError(err) + * setFormError(error.metadata?.field, error.message) + * } + * } + * } + * ``` + * + * ## API Endpoint Validation + * + * ```typescript + * import { z } from 'zod' + * import { validateRequest, ValidationErrorX } from '@bombillazo/error-x' + * + * const createUserSchema = z.object({ + * username: z.string().min(3).max(20).regex(/^[a-z0-9_]+$/i), + * email: z.string().email(), + * password: z.string().min(8).max(100), + * role: z.enum(['user', 'admin']).default('user') + * }) + * + * // Hono handler + * app.post('/api/users', async (c) => { + * const body = await c.req.json() + * const validation = validateRequest(createUserSchema, body) + * + * if (!validation.success) { + * return c.json(validation.errorResponse, 400) + * } + * + * const user = await db.user.create({ data: validation.data }) + * return c.json({ success: true, data: user }, 201) + * }) + * ``` + * + * ## Complex Validation with Refinements + * + * ```typescript + * import { z } from 'zod' + * import { validateForm } from '@bombillazo/error-x' + * + * const registrationSchema = z.object({ + * email: z.string().email(), + * password: z.string().min(8).regex( + * /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, + * 'Password must contain lowercase, uppercase, and number' + * ), + * confirmPassword: z.string(), + * age: z.number().min(13, 'Must be at least 13 years old'), + * acceptTerms: z.literal(true, { + * errorMap: () => ({ message: 'You must accept the terms' }) + * }) + * }) + * .refine(data => data.password === data.confirmPassword, { + * message: 'Passwords do not match', + * path: ['confirmPassword'] + * }) + * .refine(async data => { + * const exists = await checkEmailExists(data.email) + * return !exists + * }, { + * message: 'Email already registered', + * path: ['email'] + * }) + * + * // In component + * const RegisterForm = () => { + * const [errors, setErrors] = useState({}) + * + * const handleSubmit = async (e: FormEvent) => { + * e.preventDefault() + * const formData = new FormData(e.target as HTMLFormElement) + * const data = Object.fromEntries(formData) + * + * const result = await safeValidateAsync(registrationSchema, { + * ...data, + * age: Number(data.age), + * acceptTerms: data.acceptTerms === 'on' + * }) + * + * if (!result.success) { + * setErrors(convertToFormErrors(result.error)) + * return + * } + * + * await register(result.data) + * } + * + * return ( + *
+ * + * + * + * {/* ... */} + * + * ) + * } + * ``` + * + * ## Type-Safe Environment Variables + * + * ```typescript + * import { z } from 'zod' + * import { ValidationErrorX } from '@bombillazo/error-x' + * + * const envSchema = z.object({ + * NODE_ENV: z.enum(['development', 'production', 'test']), + * DATABASE_URL: z.string().url(), + * API_KEY: z.string().min(32), + * PORT: z.string().transform(Number).pipe(z.number().min(1024).max(65535)), + * DEBUG: z.string().transform(v => v === 'true').default('false') + * }) + * + * export const loadEnv = () => { + * const result = envSchema.safeParse(process.env) + * + * if (!result.success) { + * const error = ValidationErrorX.fromZodError(result.error, { + * message: 'Invalid environment configuration' + * }) + * console.error('Environment validation failed:') + * result.error.issues.forEach(issue => { + * console.error(` ${issue.path.join('.')}: ${issue.message}`) + * }) + * throw error + * } + * + * return result.data + * } + * + * // Usage + * export const env = loadEnv() + * ``` + */ + +export { + ErrorX, + ValidationErrorX, + AggregateErrorX, + z, + ZodError, +} From 978d478a3b4013b220a78f5864241df060bf2f4e Mon Sep 17 00:00:00 2001 From: bombillazo Date: Sun, 25 Jan 2026 16:57:59 -0400 Subject: [PATCH 3/5] refactor: reorganize imports and improve formatting in observability module --- src/__tests__/observability.test.ts | 2 +- src/index.ts | 8 ++++---- src/observability.ts | 13 ++++++++----- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/__tests__/observability.test.ts b/src/__tests__/observability.test.ts index 67afa92..d33b17d 100644 --- a/src/__tests__/observability.test.ts +++ b/src/__tests__/observability.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it, vi } from 'vitest'; import { ErrorX } from '../error'; import { generateFingerprint, + type OtelSpanLike, recordError, toLogEntry, toOtelAttributes, - type OtelSpanLike, } from '../observability'; describe('generateFingerprint', () => { diff --git a/src/index.ts b/src/index.ts index 624e227..f44817f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,15 @@ export { AggregateErrorX, ErrorX, type ErrorXConfig } from './error'; export { - generateFingerprint, - recordError, - toLogEntry, - toOtelAttributes, type ErrorLogEntry, type FingerprintOptions, + generateFingerprint, type LogEntryOptions, type OtelAttributeOptions, type OtelErrorAttributes, type OtelSpanLike, + recordError, + toLogEntry, + toOtelAttributes, } from './observability'; export { type DBErrorPreset, diff --git a/src/observability.ts b/src/observability.ts index ec5bd0d..645354d 100644 --- a/src/observability.ts +++ b/src/observability.ts @@ -239,8 +239,13 @@ export const generateFingerprint = (error: ErrorX, options?: FingerprintOptions) * @public */ export const toLogEntry = (error: ErrorX, options?: LogEntryOptions): ErrorLogEntry => { - const { level = 'error', includeStack = false, includeFull = false, fingerprintOptions, context } = - options ?? {}; + const { + level = 'error', + includeStack = false, + includeFull = false, + fingerprintOptions, + context, + } = options ?? {}; const fingerprint = generateFingerprint(error, fingerprintOptions); const chain = error.chain; @@ -341,9 +346,7 @@ export const toOtelAttributes = ( // Check if this is an aggregate error by checking for 'errors' property const isAggregate = 'errors' in error && Array.isArray((error as { errors?: unknown[] }).errors); - const aggregateCount = isAggregate - ? (error as { errors: unknown[] }).errors.length - : undefined; + const aggregateCount = isAggregate ? (error as { errors: unknown[] }).errors.length : undefined; const attributes: OtelErrorAttributes & Record = { 'exception.type': error.name, From 5309820c37dfb57d6c9f496ee6c5583f927b53b4 Mon Sep 17 00:00:00 2001 From: bombillazo Date: Sun, 25 Jan 2026 16:58:08 -0400 Subject: [PATCH 4/5] feat: add observability utilities for error fingerprinting, structured logging, and OpenTelemetry integration --- LLMS.md | 78 ++++++++++++++++++++- README.md | 197 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+), 1 deletion(-) diff --git a/LLMS.md b/LLMS.md index 4ffa5c2..1c9707d 100644 --- a/LLMS.md +++ b/LLMS.md @@ -5,7 +5,7 @@ ## Quick Start ```typescript -import { ErrorX, AggregateErrorX, HTTPErrorX, DBErrorX, ValidationErrorX } from '@bombillazo/error-x'; +import { ErrorX, AggregateErrorX, HTTPErrorX, DBErrorX, ValidationErrorX, toLogEntry, generateFingerprint } from '@bombillazo/error-x'; // Basic usage throw new ErrorX({ message: 'Operation failed', code: 'OP_FAILED' }); @@ -31,6 +31,10 @@ const error = new ErrorX<{ userId: number }>({ metadata: { userId: 123 } }); console.log(error.metadata?.userId); // TypeScript knows this is number + +// Observability (logging, fingerprinting, OpenTelemetry) +const logEntry = toLogEntry(error, { includeStack: true }); +const fingerprint = generateFingerprint(error); ``` ## Core Concepts @@ -264,6 +268,68 @@ class PaymentErrorX extends ErrorX { PaymentErrorX.create('DECLINED', { metadata: { transactionId: 'tx_123' } }); ``` +## Observability + +Built-in utilities for error fingerprinting, structured logging, and OpenTelemetry integration. + +### Functions + +```typescript +import { + generateFingerprint, + toLogEntry, + toOtelAttributes, + recordError, +} from '@bombillazo/error-x'; + +// Fingerprinting for deduplication +const fingerprint = generateFingerprint(error); +generateFingerprint(error, { + includeCode: true, + includeName: true, + includeMessage: true, + includeMetadataKeys: ['userId'], +}); + +// Structured logging (pino, winston compatible) +const logEntry = toLogEntry(error); +// { level, message, fingerprint, errorName, errorCode, timestamp, timestampIso, httpStatus?, metadata?, chainDepth, rootCause? } + +toLogEntry(error, { + level: 'warn', // 'error' | 'warn' | 'info' + includeStack: true, // include stack trace + includeFull: true, // include full serialized error + context: { requestId: 'req-123' }, +}); + +// OpenTelemetry span attributes +const attrs = toOtelAttributes(error); +// { 'exception.type', 'exception.message', 'exception.stacktrace', 'error.code', 'error.fingerprint', 'error.chain_depth', 'error.is_aggregate', 'error.timestamp', 'http.status_code'? } + +toOtelAttributes(error, { + includeStack: true, + includeMetadata: true, + metadataPrefix: 'app.error.', +}); + +// Helper to apply error to OTel span +const { attributes, applyToSpan } = recordError(error); +applyToSpan(span, { setStatus: true, recordException: true }); +``` + +### Types + +```typescript +import type { + FingerprintOptions, + ErrorLogEntry, + LogEntryOptions, + OtelErrorAttributes, + OtelAttributeOptions, + OtelSpanLike, +} from '@bombillazo/error-x'; +``` + ## Type Exports ```typescript @@ -305,6 +371,16 @@ import type { ValidationErrorXMetadata, // { field?, path?, zodCode?, expected?, ... } ZodIssue, // Zod issue structure } from '@bombillazo/error-x'; + +// Observability types +import type { + FingerprintOptions, // Options for generateFingerprint() + ErrorLogEntry, // Structured log entry format + LogEntryOptions, // Options for toLogEntry() + OtelErrorAttributes, // OpenTelemetry span attributes + OtelAttributeOptions, // Options for toOtelAttributes() + OtelSpanLike, // Minimal span interface for compatibility +} from '@bombillazo/error-x'; ``` ## UI Message Objects diff --git a/README.md b/README.md index 31af50f..5213f70 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![npm](https://img.shields.io/npm/dt/@bombillazo/error-x.svg?style=for-the-badge)](https://www.npmjs.com/package/@bombillazo/error-x) [![npm](https://img.shields.io/npm/l/@bombillazo/error-x?style=for-the-badge)](https://github.com/bombillazo/error-x/blob/master/LICENSE) [![codecov](https://img.shields.io/codecov/c/github/bombillazo/error-x?style=for-the-badge)](https://codecov.io/gh/bombillazo/error-x) +[![bundle size](https://img.shields.io/bundlephobia/minzip/@bombillazo/error-x?style=for-the-badge&label=bundle)](https://bundlephobia.com/package/@bombillazo/error-x) 🚨❌ @@ -21,6 +22,7 @@ A smart, isomorphic, and type-safe error library for TypeScript applications. Pr - **Global configuration** for stack cleaning and defaults - **Serialization/deserialization** for network transfer and storage - **ErrorXResolver** for i18n, documentation URLs, and custom presentation logic +- **Observability** - fingerprinting, structured logging, OpenTelemetry integration - **Custom ErrorX class** examples: - `HTTPErrorX` - HTTP status code presets (400-511) - `DBErrorX` - Database error presets (connection, query, constraints) @@ -850,6 +852,201 @@ const resolver = new ErrorXResolver({ }); ``` +## Observability + +error-x provides built-in observability utilities for error fingerprinting, structured logging, and OpenTelemetry integration. + +### Error Fingerprinting + +Generate stable fingerprints for error deduplication and grouping: + +```typescript +import { generateFingerprint } from "@bombillazo/error-x"; + +const error = new ErrorX({ + message: "Database connection failed", + code: "DB_CONN_FAILED", + name: "DatabaseError", +}); + +const fingerprint = generateFingerprint(error); +// → "a1b2c3d4" (stable hash based on error properties) + +// Same error type always produces the same fingerprint +const error2 = new ErrorX({ + message: "Database connection failed", + code: "DB_CONN_FAILED", + name: "DatabaseError", +}); +generateFingerprint(error2) === fingerprint; // true + +// Customize what's included in the fingerprint +generateFingerprint(error, { + includeCode: true, // default: true + includeName: true, // default: true + includeMessage: true, // default: true + includeMetadataKeys: ["userId", "endpoint"], // specific metadata keys + hashFunction: customHashFn, // custom hash function +}); +``` + +### Structured Logging + +Create structured log entries compatible with pino, winston, and other logging libraries: + +```typescript +import { toLogEntry } from "@bombillazo/error-x"; + +const error = new ErrorX({ + message: "User not found", + code: "USER_NOT_FOUND", + httpStatus: 404, + metadata: { userId: 123 }, +}); + +const logEntry = toLogEntry(error); +// { +// level: 'error', +// message: 'User not found', +// fingerprint: 'abc123', +// errorName: 'Error', +// errorCode: 'USER_NOT_FOUND', +// timestamp: 1704067200000, +// timestampIso: '2024-01-01T00:00:00.000Z', +// httpStatus: 404, +// metadata: { userId: 123 }, +// chainDepth: 1, +// } + +// With options +const debugEntry = toLogEntry(error, { + level: "warn", // 'error' | 'warn' | 'info' + includeStack: true, // include stack trace + includeFull: true, // include full serialized error + context: { requestId: "req-123" }, // merge additional context +}); + +// Use with pino +import pino from "pino"; +const logger = pino(); +logger.error(toLogEntry(error, { includeStack: true })); +``` + +### OpenTelemetry Integration + +Create span attributes following OpenTelemetry semantic conventions: + +```typescript +import { toOtelAttributes, recordError } from "@bombillazo/error-x"; +import { trace, SpanStatusCode } from "@opentelemetry/api"; + +const tracer = trace.getTracer("my-service"); +const span = tracer.startSpan("operation"); + +try { + await riskyOperation(); +} catch (err) { + const error = ErrorX.from(err); + + // Get OTel-compatible attributes + const attributes = toOtelAttributes(error); + // { + // 'exception.type': 'DatabaseError', + // 'exception.message': 'Connection failed', + // 'exception.stacktrace': '...', + // 'error.code': 'DB_CONN_FAILED', + // 'error.fingerprint': 'abc123', + // 'error.chain_depth': 1, + // 'error.is_aggregate': false, + // 'error.timestamp': 1704067200000, + // 'http.status_code': 500, + // } + + span.setAttributes(attributes); + span.recordException(error); + span.setStatus({ code: SpanStatusCode.ERROR, message: error.message }); +} + +// Or use the helper function +const { attributes, applyToSpan } = recordError(error); +applyToSpan(span, { setStatus: true, recordException: true }); + +// Include metadata as span attributes +const attrs = toOtelAttributes(error, { + includeStack: true, // default: true + includeMetadata: true, // default: false + metadataPrefix: "app.error.", // default: 'error.metadata.' +}); +// Includes: { 'app.error.userId': 123, ... } +``` + +### Observability API Reference + +| Function | Description | +| -------- | ----------- | +| `generateFingerprint(error, opts?)` | Generate stable hash for error deduplication | +| `toLogEntry(error, opts?)` | Create structured log entry for logging libraries | +| `toOtelAttributes(error, opts?)` | Create OpenTelemetry span attributes | +| `recordError(error, opts?)` | Helper to apply error info to OTel spans | + +--- + +## Ecosystem Integrations + +error-x includes example integrations with popular frameworks and libraries in the `/examples` directory: + +### Server Frameworks + +**Hono.js / Express.js** - Error handling middleware with consistent JSON responses, request ID tracking, and structured logging. + +```typescript +// Hono.js +import { HTTPErrorX } from "@bombillazo/error-x"; + +app.get("/user/:id", async (c) => { + const user = await getUser(c.req.param("id")); + if (!user) { + throw HTTPErrorX.create(404, { message: "User not found" }); + } + return c.json(user); +}); + +app.onError(errorMiddleware()); // Consistent JSON error responses +``` + +### Frontend + +**React Error Boundaries** - Error boundary components with ErrorX integration, user-friendly error display, and error tracking hooks. + +```tsx + trackError(error)}> + + +``` + +### API Frameworks + +**tRPC** - Type-safe error handling with ErrorX-to-TRPCError conversion and custom error formatting. + +**GraphQL** - Apollo Server and graphql-yoga integrations with consistent error extensions and user-friendly messages. + +### Logging + +**Pino / Winston** - Structured error logging with fingerprinting, deduplication, and request context. + +```typescript +import { toLogEntry, generateFingerprint } from "@bombillazo/error-x"; + +const entry = toLogEntry(error, { includeStack: true }); +logger.error(entry); +``` + +### Validation + +**Zod** - Advanced validation patterns beyond the built-in `ValidationErrorX.fromZodError()`. + +See the [/examples](./examples) directory for complete integration examples with usage documentation. + ## License MIT From f072b91fce45f0f88fc90bf5b857afd1727c6683 Mon Sep 17 00:00:00 2001 From: bombillazo Date: Sun, 25 Jan 2026 17:35:41 -0400 Subject: [PATCH 5/5] fix: update repository and bugs fields in package.json for better structure --- package.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 11b130e..cc6ee5c 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,13 @@ "publishConfig": { "access": "public" }, - "repository": "bombillazo/error-x.git", + "repository": { + "type": "git", + "url": "https://github.com/bombillazo/error-x.git" + }, + "bugs": { + "url": "https://github.com/bombillazo/error-x/issues" + }, "scripts": { "api-docs": "api-extractor run --local", "api-docs:build": "pnpm build && mkdir -p ./etc && pnpm api-docs && pnpm api-docs:markdown && rm -rf ./etc ./temp",