From f9bb11c9bd9d58209f9620ac38f744a442b46c76 Mon Sep 17 00:00:00 2001 From: uboho-prog Date: Thu, 28 May 2026 11:05:46 +0000 Subject: [PATCH] feat:Add structured logging with correlation IDs and centralized aggregation --- backend/services/__tests__/logging.test.ts | 74 ++++++ .../__tests__/loggingDashboard.test.ts | 25 ++ backend/services/alerting.ts | 7 +- backend/services/billing/metering_service.ts | 10 +- backend/services/gdpr.ts | 9 +- backend/services/index.ts | 1 + backend/services/logging.ts | 249 +++++++++++++++--- backend/services/loggingDashboard.ts | 24 ++ .../notifications/preference_service.ts | 4 +- backend/services/retentionService.ts | 6 +- backend/services/types.ts | 1 + docs/api-logging.md | 17 ++ src/services/gdpr.ts | 28 +- src/services/logging.ts | 119 +++++++++ src/services/walletService.ts | 18 +- 15 files changed, 537 insertions(+), 55 deletions(-) create mode 100644 backend/services/__tests__/logging.test.ts create mode 100644 backend/services/__tests__/loggingDashboard.test.ts create mode 100644 backend/services/loggingDashboard.ts create mode 100644 src/services/logging.ts diff --git a/backend/services/__tests__/logging.test.ts b/backend/services/__tests__/logging.test.ts new file mode 100644 index 0000000..9bd3f1d --- /dev/null +++ b/backend/services/__tests__/logging.test.ts @@ -0,0 +1,74 @@ +import { logger, queryLogs, clearLogBuffer, runWithLogContext } from '../logging'; + +describe('backend logging service', () => { + beforeEach(() => { + clearLogBuffer(); + jest.restoreAllMocks(); + }); + + it('records structured log entries and includes service/module metadata', () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + logger.info('Service started', { feature: 'billing' }); + + const entries = queryLogs({ text: 'Service started' }); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + message: 'Service started', + level: 'info', + service: 'subtrackr-backend', + module: 'backend', + meta: { feature: 'billing' }, + }); + expect(entries[0].timestamp).toBeDefined(); + expect(spy).toHaveBeenCalled(); + }); + + it('propagates correlation ids across async boundaries', async () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + await runWithLogContext('corr-id-123', async () => { + logger.debug('Async operation started', { userId: 'user-1' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + logger.info('Async operation completed'); + }); + + const entries = queryLogs({ correlationId: 'corr-id-123' }); + expect(entries.length).toBe(2); + expect(entries[0].correlationId).toBe('corr-id-123'); + expect(entries[1].correlationId).toBe('corr-id-123'); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('redacts sensitive fields in metadata', () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + logger.warn('User data access', { + userId: 'user-2', + email: 'jane@doe.com', + password: 'secret123', + cardNumber: '4111111111111111', + }); + + const entries = queryLogs({ level: 'warn' }); + expect(entries).toHaveLength(1); + expect(entries[0].meta).toEqual({ + userId: 'user-2', + email: '[REDACTED]', + password: '[REDACTED]', + cardNumber: '[REDACTED]', + }); + expect(spy).toHaveBeenCalled(); + }); + + it('filters the log buffer by module and text', () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + logger.info('First message', { detail: 'one' }); + const childLogger = logger.child('parser'); + childLogger.error('Parse failed', { line: 42 }); + + const entries = queryLogs({ module: 'parser', text: 'Parse failed' }); + expect(entries).toHaveLength(1); + expect(entries[0].module).toContain('parser'); + expect(entries[0].message).toBe('Parse failed'); + expect(entries[0].meta).toEqual({ line: 42 }); + expect(spy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/backend/services/__tests__/loggingDashboard.test.ts b/backend/services/__tests__/loggingDashboard.test.ts new file mode 100644 index 0000000..2b7faf6 --- /dev/null +++ b/backend/services/__tests__/loggingDashboard.test.ts @@ -0,0 +1,25 @@ +import { clearLogBuffer, logger } from '../logging'; +import { getLogDashboard } from '../loggingDashboard'; + +describe('logging dashboard service', () => { + beforeEach(() => { + clearLogBuffer(); + jest.restoreAllMocks(); + }); + + it('returns filtered log entries and total count', () => { + logger.info('First entry', { feature: 'dashboard' }); + logger.error('Second entry', { feature: 'dashboard', error: 'boom' }); + logger.debug('Debug entry', { feature: 'dashboard' }); + + const result = getLogDashboard({ level: 'error' }, 20); + + expect(result.total).toBe(1); + expect(result.entries).toHaveLength(1); + expect(result.entries[0]).toMatchObject({ + level: 'error', + message: 'Second entry', + meta: { feature: 'dashboard', error: 'boom' }, + }); + }); +}); diff --git a/backend/services/alerting.ts b/backend/services/alerting.ts index c35911b..0c468b8 100644 --- a/backend/services/alerting.ts +++ b/backend/services/alerting.ts @@ -3,6 +3,7 @@ * Channels are pluggable; add as many as needed. */ +import { logger } from './logging'; import type { Alert, AlertChannelConfig } from './types'; export interface AlertDispatcher { @@ -15,7 +16,11 @@ class ConsoleDispatcher implements AlertDispatcher { async send(alert: Alert): Promise { const prefix = alert.severity === 'critical' ? '🚨' : alert.severity === 'warning' ? '⚠️' : 'ℹ️'; - console.log(`${prefix} [${alert.severity.toUpperCase()}] ${alert.title}: ${alert.message}`); + logger.info(`${prefix} [${alert.severity.toUpperCase()}] ${alert.title}: ${alert.message}`, { + correlationId: alert.correlationId, + alertId: alert.id, + severity: alert.severity, + }); } } diff --git a/backend/services/billing/metering_service.ts b/backend/services/billing/metering_service.ts index cb35841..1c98138 100644 --- a/backend/services/billing/metering_service.ts +++ b/backend/services/billing/metering_service.ts @@ -5,19 +5,25 @@ export interface UsageMetric { timestamp: Date; } +import { logger } from '../logging'; + export class MeteringService { private thresholdAlerts = [0.8, 1.0, 1.2]; // 80%, 100%, 120% async recordUsage(metric: UsageMetric): Promise { // Low-latency metering pipeline integration - console.log(`Recorded ${metric.amount} for ${metric.metricType}`); + logger.info('Recorded usage metric', { + userId: metric.userId, + metricType: metric.metricType, + amount: metric.amount, + }); await this.checkThresholds(metric.userId); } async checkThresholds(userId: string): Promise { // Check usage against thresholds and trigger alerts - console.log(`Checked thresholds for ${userId}`); + logger.debug('Checked thresholds for user usage', { userId }); } async calculateOverage(userId: string): Promise { diff --git a/backend/services/gdpr.ts b/backend/services/gdpr.ts index 1e7e102..6f562ee 100644 --- a/backend/services/gdpr.ts +++ b/backend/services/gdpr.ts @@ -7,6 +7,7 @@ import { } from './encryption'; import { keyManager } from './keyManager'; import { piiAuditService } from './piiAudit'; +import { logger } from './logging'; export interface UserConsent { analytics: boolean; @@ -47,7 +48,7 @@ function generateExportId(): string { export const exportUserData = async (userId: string): Promise => { await ensureEncryptionInitialized(); - console.log(`Exporting data for user: ${userId}`); + logger.info('Exporting user data', { userId }); const userData = { profile: { id: userId, email: 'user@example.com', name: 'John Doe', registeredAt: '2026-01-01' }, @@ -98,7 +99,7 @@ export const deleteUserData = async ( ): Promise => { await ensureEncryptionInitialized(); - console.log(`Processing deletion for user: ${userId} (Permanent: ${permanent})`); + logger.info('Processing user deletion', { userId, permanent }); if (!permanent) { return anonymizeUserData(userId) as Promise; @@ -119,7 +120,7 @@ export const deleteUserData = async ( export const anonymizeUserData = async (userId: string): Promise => { await ensureEncryptionInitialized(); - console.log(`Anonymizing data for user: ${userId}`); + logger.info('Anonymizing user data', { userId }); const fields = ['email', 'name', 'phoneNumber', 'address', 'businessName', 'recipientEmail']; @@ -144,7 +145,7 @@ export const updateConsent = async ( timestamp: new Date().toISOString(), }; - console.log(`Consent updated for ${userId}:`, newConsent); + logger.info('Consent updated', { userId, newConsent }); return newConsent; }; diff --git a/backend/services/index.ts b/backend/services/index.ts index a1919f0..5d593f1 100644 --- a/backend/services/index.ts +++ b/backend/services/index.ts @@ -1,6 +1,7 @@ export { AuditService } from './auditService'; export { CampaignService } from './campaignService'; export { DunningService, dunningService } from './dunningService'; +export { getLogDashboard, LogQueryFilter, LogDashboardPage } from './loggingDashboard'; export { PricingService } from './pricingService'; export { OracleMonitorService, oracleMonitorService } from './oracleMonitorService'; export { RateLimitingService, rateLimitingService } from './rateLimitingService'; diff --git a/backend/services/logging.ts b/backend/services/logging.ts index 5524d8c..4d14d7c 100644 --- a/backend/services/logging.ts +++ b/backend/services/logging.ts @@ -1,3 +1,5 @@ +import { AsyncLocalStorage } from 'async_hooks'; + export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; const LOG_LEVEL_PRIORITY: Record = { @@ -7,59 +9,246 @@ const LOG_LEVEL_PRIORITY: Record = { error: 3, }; -// Change this via env later -const CURRENT_LEVEL: LogLevel = __DEV__ ? 'debug' : 'info'; +const DEFAULT_LOG_LEVEL: LogLevel = 'info'; +const DEFAULT_BUFFER_SIZE = 200; +const SERVICE_NAME = process.env.LOG_SERVICE_NAME || 'subtrackr-backend'; +const REMOTE_LOG_ENDPOINT = process.env.LOG_REMOTE_ENDPOINT || ''; +const GLOBAL_LOG_LEVEL = (process.env.BACKEND_LOG_LEVEL as LogLevel) || DEFAULT_LOG_LEVEL; +const BUFFER_SIZE = Number(process.env.LOG_BUFFER_SIZE || DEFAULT_BUFFER_SIZE); -// Correlation ID generator (simple version) -const generateId = () => { - return Math.random().toString(36).substring(2) + Date.now().toString(36); -}; +const SENSITIVE_FIELD_PATTERNS = [ + /password/i, + /secret/i, + /token/i, + /ssn/i, + /creditcard/i, + /cardNumber/i, + /email/i, + /phone/i, + /accountNumber/i, + /routingNumber/i, +]; + +const asyncLocalStorage = new AsyncLocalStorage(); +const inMemoryLogBuffer: LogEntry[] = []; export interface LogContext { - [key: string]: any; correlationId?: string; + [key: string]: unknown; +} + +export interface LogMeta { + [key: string]: unknown; +} + +export interface LogEntry { + timestamp: string; + service: string; + module: string; + level: LogLevel; + message: string; + correlationId?: string; + meta?: LogMeta; +} + +function generateId(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`; +} + +function parseModuleLevels(envValue: string): Record { + return envValue.split(',').reduce((acc, pair) => { + const [module, level] = pair.split(':').map((part) => part.trim()); + if (module && level && ['debug', 'info', 'warn', 'error'].includes(level)) { + acc[module] = level as LogLevel; + } + return acc; + }, {} as Record); +} + +const MODULE_LOG_LEVELS = parseModuleLevels(process.env.BACKEND_LOG_LEVELS || ''); + +function getModuleLevel(moduleName: string): LogLevel { + const exactMatch = MODULE_LOG_LEVELS[moduleName]; + if (exactMatch) return exactMatch; + + const partialMatch = Object.keys(MODULE_LOG_LEVELS).find((key) => moduleName.startsWith(`${key}:`)); + if (partialMatch) return MODULE_LOG_LEVELS[partialMatch]; + + return GLOBAL_LOG_LEVEL; +} + +function isSensitiveField(key: string): boolean { + return SENSITIVE_FIELD_PATTERNS.some((pattern) => pattern.test(key)); +} + +function redactValue(key: string, value: unknown): unknown { + if (typeof value === 'string') { + return isSensitiveField(key) ? '[REDACTED]' : value; + } + + if (Array.isArray(value)) { + return value.map((item) => redactValue(key, item)); + } + + if (value && typeof value === 'object') { + return redactSensitiveFields(value as Record); + } + + return value; } -function shouldLog(level: LogLevel) { - return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[CURRENT_LEVEL]; +function redactSensitiveFields(obj: Record): Record { + return Object.entries(obj).reduce((acc, [key, value]) => { + if (isSensitiveField(key)) { + acc[key] = '[REDACTED]'; + } else if (Array.isArray(value)) { + acc[key] = value.map((item) => (typeof item === 'object' ? redactSensitiveFields(item as Record) : item)); + } else if (value && typeof value === 'object') { + acc[key] = redactSensitiveFields(value as Record); + } else { + acc[key] = value; + } + return acc; + }, {} as Record); } -function formatLog(level: LogLevel, message: string, context?: LogContext) { +function sanitizeMeta(meta?: LogMeta): LogMeta | undefined { + if (!meta) return undefined; + return redactSensitiveFields(meta as Record); +} + +function shouldLog(level: LogLevel, moduleName: string) { + const moduleLevel = getModuleLevel(moduleName); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[moduleLevel]; +} + +function formatLog(level: LogLevel, message: string, meta: LogMeta | undefined, moduleName: string, context: LogContext): LogEntry { return { + timestamp: new Date().toISOString(), + service: SERVICE_NAME, + module: moduleName, level, message, - timestamp: new Date().toISOString(), - ...context, + correlationId: context.correlationId, + meta: meta && Object.keys(meta).length ? sanitizeMeta(meta) : undefined, }; } -function sendToConsole(logEntry: any) { - console.log(JSON.stringify(logEntry, null, 2)); +function enqueueLog(entry: LogEntry) { + inMemoryLogBuffer.push(entry); + while (inMemoryLogBuffer.length > BUFFER_SIZE) { + inMemoryLogBuffer.shift(); + } +} + +function sendToConsole(entry: LogEntry) { + console.log(JSON.stringify(entry)); } -// future: plug Sentry / API here -async function sendToRemote(_logEntry: any) { - // Example: - // await fetch("https://your-api/logs", { method: "POST", body: JSON.stringify(logEntry) }); +async function sendToRemote(entry: LogEntry) { + if (!REMOTE_LOG_ENDPOINT) return; + + try { + await fetch(REMOTE_LOG_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(entry), + }); + } catch (error) { + console.warn(JSON.stringify({ + level: 'warn', + message: 'Failed to send log to remote endpoint', + endpoint: REMOTE_LOG_ENDPOINT, + error: String(error), + correlationId: entry.correlationId, + })); + } } -function log(level: LogLevel, message: string, context?: LogContext) { - if (!shouldLog(level)) return; +function getCurrentContext(): LogContext { + return asyncLocalStorage.getStore() ?? {}; +} - const logEntry = formatLog(level, message, context); +function buildLogEntry(level: LogLevel, message: string, meta: LogMeta | undefined, moduleName: string): LogEntry { + const context = getCurrentContext(); + const correlationId = context.correlationId || generateId(); - sendToConsole(logEntry); + return formatLog(level, message, meta, moduleName, { + ...context, + correlationId, + }); +} - if (level === 'error') { - sendToRemote(logEntry); +function recordLog(entry: LogEntry) { + enqueueLog(entry); + sendToConsole(entry); + if (entry.level === 'error') { + void sendToRemote(entry); } } -export const logger = { - debug: (msg: string, ctx?: LogContext) => log('debug', msg, ctx), - info: (msg: string, ctx?: LogContext) => log('info', msg, ctx), - warn: (msg: string, ctx?: LogContext) => log('warn', msg, ctx), - error: (msg: string, ctx?: LogContext) => log('error', msg, ctx), +function log(level: LogLevel, message: string, meta: LogMeta | undefined, moduleName: string) { + if (!shouldLog(level, moduleName)) return; + + const entry = buildLogEntry(level, message, meta, moduleName); + recordLog(entry); +} + +export interface Logger { + debug(message: string, meta?: LogMeta): void; + info(message: string, meta?: LogMeta): void; + warn(message: string, meta?: LogMeta): void; + error(message: string, meta?: LogMeta): void; + child(moduleName: string): Logger; + withContext(context: LogContext | string, fn: () => T): T; + getCorrelationId(): string; + createCorrelationId(): string; +} + +function createLogger(moduleName: string): Logger { + const logger = { + debug: (message: string, meta?: LogMeta) => log('debug', message, meta, moduleName), + info: (message: string, meta?: LogMeta) => log('info', message, meta, moduleName), + warn: (message: string, meta?: LogMeta) => log('warn', message, meta, moduleName), + error: (message: string, meta?: LogMeta) => log('error', message, meta, moduleName), + child: (childModule: string) => createLogger(`${moduleName}:${childModule}`), + withContext: (context: LogContext | string, fn: () => T): T => { + const store: LogContext = typeof context === 'string' ? { correlationId: context } : context; + return asyncLocalStorage.run(store, fn); + }, + getCorrelationId: (): string => getCurrentContext().correlationId || '', + createCorrelationId: generateId, + }; + + return logger; +} + +export function queryLogs(filter: { + level?: LogLevel; + module?: string; + correlationId?: string; + text?: string; + from?: string; + to?: string; +} = {}): LogEntry[] { + return inMemoryLogBuffer.filter((entry) => { + if (filter.level && entry.level !== filter.level) return false; + if (filter.module && !entry.module.includes(filter.module)) return false; + if (filter.correlationId && entry.correlationId !== filter.correlationId) return false; + if (filter.text && !entry.message.includes(filter.text) && !(entry.meta && JSON.stringify(entry.meta).includes(filter.text))) return false; + if (filter.from && entry.timestamp < filter.from) return false; + if (filter.to && entry.timestamp > filter.to) return false; + return true; + }); +} + +export function clearLogBuffer(): void { + inMemoryLogBuffer.length = 0; +} - createCorrelationId: generateId, +export const logger = createLogger('backend'); +export const createLoggerFor = createLogger; +export const runWithLogContext = (context: LogContext | string, fn: () => T): T => { + const store: LogContext = typeof context === 'string' ? { correlationId: context } : context; + return asyncLocalStorage.run(store, fn); }; diff --git a/backend/services/loggingDashboard.ts b/backend/services/loggingDashboard.ts new file mode 100644 index 0000000..abb7e7d --- /dev/null +++ b/backend/services/loggingDashboard.ts @@ -0,0 +1,24 @@ +import { LogEntry, queryLogs } from './logging'; + +export interface LogQueryFilter { + level?: 'debug' | 'info' | 'warn' | 'error'; + module?: string; + correlationId?: string; + text?: string; + from?: string; + to?: string; +} + +export interface LogDashboardPage { + total: number; + entries: LogEntry[]; +} + +export function getLogDashboard(filter: LogQueryFilter = {}, pageSize = 50): LogDashboardPage { + const entries = queryLogs(filter); + const sortedEntries = entries.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + return { + total: sortedEntries.length, + entries: sortedEntries.slice(0, pageSize), + }; +} diff --git a/backend/services/notifications/preference_service.ts b/backend/services/notifications/preference_service.ts index 722334f..7eef91e 100644 --- a/backend/services/notifications/preference_service.ts +++ b/backend/services/notifications/preference_service.ts @@ -15,6 +15,8 @@ export interface NotificationPreferences { }; } +import { logger } from '../logging'; + export class NotificationPreferenceService { async getPreferences(userId: string): Promise { // Mock database fetch @@ -23,7 +25,7 @@ export class NotificationPreferenceService { async updatePreferences(userId: string, prefs: Partial): Promise { // Cross-device synchronization logic - console.log(`Updated preferences for user ${userId}`); + logger.info('Updated notification preferences for user', { userId, prefs }); return true; } diff --git a/backend/services/retentionService.ts b/backend/services/retentionService.ts index b56ada4..b1728d9 100644 --- a/backend/services/retentionService.ts +++ b/backend/services/retentionService.ts @@ -274,7 +274,11 @@ function triggerWinBackCampaign(record: CancellationRecord): void { const delayMs = new Date(record.coolOffEndsAt).getTime() - Date.now(); setTimeout(() => { // In production: call campaignService.createWinBackCampaign(record) - console.log(`[RetentionService] Win-back campaign triggered for user ${record.userId}, sub ${record.subscriptionId}`); + logger.info('Win-back campaign triggered', { + userId: record.userId, + subscriptionId: record.subscriptionId, + coolOffEndsAt: record.coolOffEndsAt, + }); }, Math.max(0, delayMs)); } diff --git a/backend/services/types.ts b/backend/services/types.ts index 1c954b5..eddc915 100644 --- a/backend/services/types.ts +++ b/backend/services/types.ts @@ -30,6 +30,7 @@ export interface Alert { timestamp: number; resolved: boolean; ruleId: string; + correlationId?: string; } export interface AlertRule { diff --git a/docs/api-logging.md b/docs/api-logging.md index bbfae92..8c1f045 100644 --- a/docs/api-logging.md +++ b/docs/api-logging.md @@ -92,6 +92,23 @@ Use error only for actual failures Avoid logging sensitive data (private keys, auth tokens) Use debug for internal state inspection only Keep logs structured (never plain strings only) + +### Log Aggregation +The backend supports forwarding structured events to a centralized endpoint using `LOG_REMOTE_ENDPOINT`. +Set `BACKEND_LOG_LEVEL` and `BACKEND_LOG_LEVELS` to tune verbosity globally or per module. + +### Queryable Log Buffer +A local query API is available in backend services for dashboard-style filtering: +```ts +import { queryLogs } from '../backend/services/logging'; +const logs = queryLogs({ + level: 'error', + module: 'backend:gdpr', + correlationId: 'corr-id-123', + text: 'export', +}); +``` + Backend Logging Coverage Logging should be used in: diff --git a/src/services/gdpr.ts b/src/services/gdpr.ts index e13ff58..5c26c22 100644 --- a/src/services/gdpr.ts +++ b/src/services/gdpr.ts @@ -1,4 +1,5 @@ import { Alert } from 'react-native'; +import { logger } from './logging'; export interface ConsentPreferences { analytics: boolean; @@ -23,13 +24,18 @@ const API_BASE = 'https://api.subtrackr.example.com/gdpr'; export const gdprService = { async exportData(): Promise { try { - return { + const response = { url: `${API_BASE}/download/export-user-123.json`, timestamp: new Date().toISOString(), encryptedFields: ['email', 'name'], }; + logger.info('GDPR export data requested', { + url: response.url, + encryptedFields: response.encryptedFields, + }); + return response; } catch (error) { - console.error('Failed to export data', error); + logger.error('Failed to export data', { error }); throw error; } }, @@ -37,30 +43,38 @@ export const gdprService = { async requestDeletion(permanent: boolean): Promise { try { if (!permanent) { - return { + const result = { success: true, message: 'User data has been anonymized', anonymizedFields: ['email', 'name', 'phoneNumber', 'address'], }; + logger.info('GDPR deletion requested', { + permanent, + anonymizedFields: result.anonymizedFields, + }); + return result; } - return { success: true, message: 'User data permanently deleted', anonymizedFields: [] }; + const result = { success: true, message: 'User data permanently deleted', anonymizedFields: [] }; + logger.info('GDPR permanent deletion requested', { permanent }); + return result; } catch (error) { - console.error('Failed to delete account', error); + logger.error('Failed to delete account', { error }); throw error; } }, async updateConsent(preferences: ConsentPreferences): Promise { try { + logger.info('GDPR consent update requested', { preferences }); return preferences; } catch (error) { - console.error('Failed to update consent', error); + logger.error('Failed to update consent', { error }); throw error; } }, async downloadData(data: unknown): Promise { - console.log('Triggering download for:', data); + logger.info('Triggering GDPR data download', { data }); Alert.alert('Success', 'Your data export has been prepared and will be sent to your email.'); }, }; diff --git a/src/services/logging.ts b/src/services/logging.ts new file mode 100644 index 0000000..f9ed834 --- /dev/null +++ b/src/services/logging.ts @@ -0,0 +1,119 @@ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LOG_LEVEL_PRIORITY: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +const DEFAULT_LOG_LEVEL: LogLevel = 'info'; +const GLOBAL_LOG_LEVEL: LogLevel = + (process.env.LOG_LEVEL as LogLevel) || DEFAULT_LOG_LEVEL; + +const SENSITIVE_FIELD_PATTERNS = [ + /password/i, + /secret/i, + /token/i, + /ssn/i, + /creditcard/i, + /cardnumber/i, + /email/i, + /phone/i, + /accountnumber/i, + /routingnumber/i, +]; + +export interface LogMeta { + [key: string]: unknown; +} + +export interface LogEntry { + timestamp: string; + level: LogLevel; + message: string; + module: string; + correlationId?: string; + meta?: LogMeta; +} + +function generateId(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`; +} + +function isSensitiveField(key: string): boolean { + return SENSITIVE_FIELD_PATTERNS.some((pattern) => pattern.test(key)); +} + +function redactSensitiveFields(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => redactSensitiveFields(item)); + } + + if (value && typeof value === 'object') { + return Object.entries(value as Record).reduce( + (acc, [key, item]) => { + acc[key] = isSensitiveField(key) + ? '[REDACTED]' + : redactSensitiveFields(item); + return acc; + }, + {} as Record + ); + } + + return value; +} + +function sanitizeMeta(meta?: LogMeta): LogMeta | undefined { + if (!meta) return undefined; + return redactSensitiveFields(meta as Record) as LogMeta; +} + +function shouldLog(level: LogLevel): boolean { + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[GLOBAL_LOG_LEVEL]; +} + +function buildLogEntry( + level: LogLevel, + message: string, + module: string, + meta?: LogMeta, + correlationId?: string +): LogEntry { + return { + timestamp: new Date().toISOString(), + level, + message, + module, + correlationId, + meta: meta && Object.keys(meta).length ? sanitizeMeta(meta) : undefined, + }; +} + +function sendToConsole(entry: LogEntry): void { + console.log(JSON.stringify(entry)); +} + +function createLogger(moduleName: string) { + const log = (level: LogLevel, message: string, meta?: LogMeta, correlationId?: string) => { + if (!shouldLog(level)) return; + const entry = buildLogEntry(level, message, moduleName, meta, correlationId); + sendToConsole(entry); + }; + + return { + debug: (message: string, meta?: LogMeta, correlationId?: string) => + log('debug', message, meta, correlationId), + info: (message: string, meta?: LogMeta, correlationId?: string) => + log('info', message, meta, correlationId), + warn: (message: string, meta?: LogMeta, correlationId?: string) => + log('warn', message, meta, correlationId), + error: (message: string, meta?: LogMeta, correlationId?: string) => + log('error', message, meta, correlationId), + child: (childModule: string) => createLogger(`${moduleName}:${childModule}`), + createCorrelationId: generateId, + }; +} + +export const logger = createLogger('app'); diff --git a/src/services/walletService.ts b/src/services/walletService.ts index 5477803..67167bf 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -1,6 +1,7 @@ import { ethers } from 'ethers'; import { Framework, SFError } from '@superfluid-finance/sdk-core'; +import { logger } from './logging'; import { ERC20__factory, getContractAddress } from '../contracts'; import { getEvmRpcUrl } from '../config/evm'; import { @@ -161,7 +162,7 @@ function toWalletError( ): WalletError { errorTracker.record(code); // Log full detail for debugging without leaking to the user - console.error(`[WalletError] ${code}:`, error); + logger.error(`WalletError ${code}`, { error, code, userMessage, recovery }); return new WalletError(code, userMessage, recovery, error); } @@ -182,9 +183,9 @@ export class WalletServiceManager { async initialize(): Promise { try { - console.log('WalletServiceManager initialized successfully'); + logger.info('WalletServiceManager initialized successfully'); } catch (error) { - console.error('Failed to initialize WalletServiceManager:', error); + logger.error('Failed to initialize WalletServiceManager', { error }); throw error; } } @@ -217,9 +218,9 @@ export class WalletServiceManager { try { this.connection = null; this.notifyListeners(); - console.log('Wallet disconnected'); + logger.info('Wallet disconnected'); } catch (error) { - console.error('Failed to disconnect wallet:', error); + logger.error('Failed to disconnect wallet', { error }); throw error; } } @@ -263,7 +264,7 @@ export class WalletServiceManager { decimals: CRYPTO_CONSTANTS.USDC_DECIMALS, }); } catch { - console.log('USDC not available on this chain'); + logger.warn('USDC not available on this chain', { chainId }); } } @@ -318,7 +319,7 @@ export class WalletServiceManager { : CRYPTO_CONSTANTS.DEFAULT_GAS_BUFFER_MULTIPLIER; gasLimit = estimated.mul(bufferMultiplier).div(100); } catch (err) { - console.warn('Gas estimation failed, using safe fallback:', err); + logger.warn('Gas estimation failed, using safe fallback', { error: err }); gasLimit = ethers.BigNumber.from(CRYPTO_CONSTANTS.FALLBACK_GAS_LIMIT); } } @@ -628,8 +629,7 @@ export class WalletServiceManager { : CRYPTO_CONSTANTS.DEFAULT_GAS_BUFFER_MULTIPLIER; gasLimit = estimated.mul(bufferMultiplier).div(100); } catch (err) { - console.warn('Approve gas estimation failed, using fallback:', err); - gasLimit = ethers.BigNumber.from(CRYPTO_CONSTANTS.FALLBACK_GAS_LIMIT); + logger.warn('Approve gas estimation failed, using fallback', { error: err }); } const estimatedCost = gasPrice.mul(gasLimit);