UNPKG

@sudowealth/schwab-api

Version:

TypeScript client for Charles Schwab API with OAuth support, market data, trading functionality, and complete type safety

509 lines (508 loc) 18.9 kB
import { createLogger } from '../utils/secure-logger'; const logger = createLogger('TokenRefreshTracer'); /** * Types of token refresh trace events */ var TokenRefreshEventType; (function (TokenRefreshEventType) { TokenRefreshEventType["REFRESH_STARTED"] = "refresh_started"; TokenRefreshEventType["REFRESH_HTTP_REQUEST"] = "refresh_http_request"; TokenRefreshEventType["REFRESH_HTTP_RESPONSE"] = "refresh_http_response"; TokenRefreshEventType["REFRESH_SUCCEEDED"] = "refresh_succeeded"; TokenRefreshEventType["REFRESH_FAILED"] = "refresh_failed"; TokenRefreshEventType["TOKEN_VALIDATION"] = "token_validation"; TokenRefreshEventType["TOKEN_USED"] = "token_used"; TokenRefreshEventType["TOKEN_SAVE"] = "token_save"; TokenRefreshEventType["TOKEN_LOAD"] = "token_load"; })(TokenRefreshEventType || (TokenRefreshEventType = {})); /** * Singleton token refresh tracer class * Provides detailed tracing of token refresh operations */ export class TokenRefreshTracer { static instance; options; traceHistory = []; activeRefreshId = null; constructor(options = {}) { this.options = { includeRawResponses: options.includeRawResponses || false, tracerCallback: options.tracerCallback, additionalContext: options.additionalContext || {}, maxHistorySize: options.maxHistorySize || 10, }; } /** * Get the singleton instance of the tracer */ static getInstance(options) { if (!TokenRefreshTracer.instance) { TokenRefreshTracer.instance = new TokenRefreshTracer(options); } else if (options) { // Update options if provided TokenRefreshTracer.instance.updateOptions(options); } return TokenRefreshTracer.instance; } /** * Update tracer options */ updateOptions(options) { this.options = { includeRawResponses: options.includeRawResponses ?? this.options.includeRawResponses, tracerCallback: options.tracerCallback ?? this.options.tracerCallback, maxHistorySize: options.maxHistorySize ?? this.options.maxHistorySize, additionalContext: { ...this.options.additionalContext, ...(options.additionalContext || {}), }, }; } /** * Start tracing a new token refresh operation */ startRefreshTrace() { const refreshId = `refresh-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`; this.activeRefreshId = refreshId; this.recordEvent({ refreshId, timestamp: new Date().toISOString(), eventType: TokenRefreshEventType.REFRESH_STARTED, details: { startedAt: new Date().toISOString(), }, context: this.options.additionalContext, }); return refreshId; } /** * Record an HTTP request for token refresh */ recordRefreshRequest(refreshId, url, method, headers, body) { refreshId = refreshId || this.activeRefreshId; if (!refreshId) return; // Sanitize headers to remove sensitive information const sanitizedHeaders = this.sanitizeHeaders(headers); // Sanitize body for token requests let sanitizedBody = undefined; if (body) { if (typeof body === 'string' && body.includes('refresh_token')) { // For URL encoded form data const params = new URLSearchParams(body); sanitizedBody = {}; params.forEach((value, key) => { if (key === 'refresh_token') { sanitizedBody[key] = value.substring(0, 8) + '...'; } else if (key === 'client_secret') { sanitizedBody[key] = '[REDACTED]'; } else { sanitizedBody[key] = value; } }); } else if (typeof body === 'object') { // For JSON objects sanitizedBody = { ...body }; if (sanitizedBody.refresh_token) { sanitizedBody.refresh_token = sanitizedBody.refresh_token.substring(0, 8) + '...'; } if (sanitizedBody.client_secret) { sanitizedBody.client_secret = '[REDACTED]'; } } } this.recordEvent({ refreshId, timestamp: new Date().toISOString(), eventType: TokenRefreshEventType.REFRESH_HTTP_REQUEST, details: { url, method, headers: sanitizedHeaders, body: sanitizedBody, }, context: this.options.additionalContext, }); } /** * Record an HTTP response from token refresh */ recordRefreshResponse(refreshId, status, headers, body) { refreshId = refreshId || this.activeRefreshId; if (!refreshId) return; // Sanitize the response body to avoid logging sensitive data let sanitizedBody; if (typeof body === 'object' && body !== null) { sanitizedBody = { ...body }; // Redact sensitive token information if (sanitizedBody.access_token) { sanitizedBody.access_token = sanitizedBody.access_token.substring(0, 8) + '...'; } if (sanitizedBody.refresh_token) { sanitizedBody.refresh_token = sanitizedBody.refresh_token.substring(0, 8) + '...'; } // Include raw response if explicitly enabled if (!this.options.includeRawResponses) { sanitizedBody._note = 'Set includeRawResponses:true to see full response'; } } else if (typeof body === 'string') { // Try to parse as JSON try { const jsonBody = JSON.parse(body); sanitizedBody = { ...jsonBody }; if (sanitizedBody.access_token) { sanitizedBody.access_token = sanitizedBody.access_token.substring(0, 8) + '...'; } if (sanitizedBody.refresh_token) { sanitizedBody.refresh_token = sanitizedBody.refresh_token.substring(0, 8) + '...'; } } catch (e) { // Not JSON, sanitize if it contains token data if (body.includes('access_token') || body.includes('refresh_token')) { sanitizedBody = '[SENSITIVE RESPONSE - REDACTED]'; } else { sanitizedBody = body.length > 100 ? body.substring(0, 100) + '...' : body; } logger.error('Error sanitizing body', e); } } else { sanitizedBody = body; } this.recordEvent({ refreshId, timestamp: new Date().toISOString(), eventType: TokenRefreshEventType.REFRESH_HTTP_RESPONSE, details: { status, headers: this.sanitizeHeaders(headers), body: sanitizedBody, rawIncluded: this.options.includeRawResponses, }, context: this.options.additionalContext, }); } /** * Record successful token refresh */ recordRefreshSuccess(refreshId, tokenData) { refreshId = refreshId || this.activeRefreshId; if (!refreshId) return; // Sanitize token data to avoid logging sensitive information const sanitizedTokenData = { hasAccessToken: !!tokenData.accessToken, accessTokenSegment: tokenData.accessToken ? tokenData.accessToken.substring(0, 8) + '...' : undefined, hasRefreshToken: !!tokenData.refreshToken, refreshTokenSegment: tokenData.refreshToken ? tokenData.refreshToken.substring(0, 8) + '...' : undefined, expiresAt: tokenData.expiresAt, expiresIn: tokenData.expiresAt ? Math.floor((tokenData.expiresAt - Date.now()) / 1000) + 's' : undefined, }; this.recordEvent({ refreshId, timestamp: new Date().toISOString(), eventType: TokenRefreshEventType.REFRESH_SUCCEEDED, details: { tokenData: sanitizedTokenData, completedAt: new Date().toISOString(), }, context: this.options.additionalContext, }); // If this is the active refresh, clear it if (refreshId === this.activeRefreshId) { this.activeRefreshId = null; } } /** * Record failed token refresh */ recordRefreshFailure(refreshId, error) { refreshId = refreshId || this.activeRefreshId; if (!refreshId) return; // Create a structured error object const errorObj = error instanceof Error ? { message: error.message, name: error.name, stack: error.stack, code: error.code, status: error.status, } : { message: String(error), name: 'Unknown Error', }; this.recordEvent({ refreshId, timestamp: new Date().toISOString(), eventType: TokenRefreshEventType.REFRESH_FAILED, details: { failedAt: new Date().toISOString(), }, error: errorObj, context: this.options.additionalContext, }); // If this is the active refresh, clear it if (refreshId === this.activeRefreshId) { this.activeRefreshId = null; } } /** * Record token validation event */ recordTokenValidation(isValid, tokenData, reason) { // Sanitize token data const sanitizedTokenData = { hasAccessToken: !!tokenData.accessToken, accessTokenSegment: tokenData.accessToken ? tokenData.accessToken.substring(0, 8) + '...' : undefined, hasRefreshToken: !!tokenData.refreshToken, expiresAt: tokenData.expiresAt, expiresIn: tokenData.expiresAt ? Math.floor((tokenData.expiresAt - Date.now()) / 1000) + 's' : undefined, isExpired: tokenData.expiresAt ? tokenData.expiresAt <= Date.now() : undefined, }; this.recordEvent({ refreshId: 'validation-' + Date.now(), timestamp: new Date().toISOString(), eventType: TokenRefreshEventType.TOKEN_VALIDATION, details: { isValid, reason, tokenData: sanitizedTokenData, }, context: this.options.additionalContext, }); } /** * Record token being used for an API request */ recordTokenUsed(url, method, tokenSegment) { this.recordEvent({ refreshId: 'usage-' + Date.now(), timestamp: new Date().toISOString(), eventType: TokenRefreshEventType.TOKEN_USED, details: { url, method, tokenSegment, }, context: this.options.additionalContext, }); } /** * Record token save operation */ recordTokenSave(success, error) { const event = { refreshId: 'save-' + Date.now(), timestamp: new Date().toISOString(), eventType: TokenRefreshEventType.TOKEN_SAVE, details: { success, timestamp: new Date().toISOString(), }, context: this.options.additionalContext, }; if (error) { event.error = error instanceof Error ? { message: error.message, name: error.name, stack: error.stack, } : { message: String(error), name: 'Unknown Error', }; } this.recordEvent(event); } /** * Record token load operation */ recordTokenLoad(success, result, error) { const event = { refreshId: 'load-' + Date.now(), timestamp: new Date().toISOString(), eventType: TokenRefreshEventType.TOKEN_LOAD, details: { success, hasTokens: result?.hasTokens, timestamp: new Date().toISOString(), }, context: this.options.additionalContext, }; if (error) { event.error = error instanceof Error ? { message: error.message, name: error.name, stack: error.stack, } : { message: String(error), name: 'Unknown Error', }; } this.recordEvent(event); } /** * Get the trace history */ getTraceHistory() { return [...this.traceHistory]; } /** * Clear the trace history */ clearTraceHistory() { this.traceHistory = []; } /** * Get detailed report of the most recent token refresh */ getLatestRefreshReport() { // Find the most recent refresh operation const refreshStartEvent = this.traceHistory .filter((e) => e.eventType === TokenRefreshEventType.REFRESH_STARTED) .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) .shift(); if (!refreshStartEvent) { return { events: [], summary: null }; } const refreshId = refreshStartEvent.refreshId; const eventsForRefresh = this.traceHistory .filter((e) => e.refreshId === refreshId) .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); // Get start and end times const startTime = refreshStartEvent.timestamp; // Find the end event - either success or failure const endEvent = eventsForRefresh.find((e) => e.eventType === TokenRefreshEventType.REFRESH_SUCCEEDED || e.eventType === TokenRefreshEventType.REFRESH_FAILED); const endTime = endEvent?.timestamp || new Date().toISOString(); const duration = new Date(endTime).getTime() - new Date(startTime).getTime(); // Count HTTP requests const httpRequestCount = eventsForRefresh.filter((e) => e.eventType === TokenRefreshEventType.REFRESH_HTTP_REQUEST).length; // Get response status code if available const responseEvent = eventsForRefresh.find((e) => e.eventType === TokenRefreshEventType.REFRESH_HTTP_RESPONSE); const statusCode = responseEvent?.details?.status; // Determine success const success = eventsForRefresh.some((e) => e.eventType === TokenRefreshEventType.REFRESH_SUCCEEDED); // Get error message if failed const failEvent = eventsForRefresh.find((e) => e.eventType === TokenRefreshEventType.REFRESH_FAILED); const errorMessage = failEvent?.error?.message; return { events: eventsForRefresh, summary: { refreshId, startTime, endTime, duration, success, httpRequestCount, statusCode, errorMessage, }, }; } /** * Private method to record a trace event */ recordEvent(event) { // Add to history, maintaining max size this.traceHistory.push(event); // Trim history if needed if (this.traceHistory.length > this.options.maxHistorySize) { this.traceHistory = this.traceHistory.slice(-this.options.maxHistorySize); } // Call callback if provided if (this.options.tracerCallback) { try { this.options.tracerCallback(event); } catch (e) { logger.error('Error calling tracer callback', e); } } } /** * Helper to sanitize headers for logging */ sanitizeHeaders(headers) { const result = {}; // If headers is undefined or null, return an empty object if (!headers) { return result; } // Make sure headers is an object that can be iterated if (typeof headers !== 'object') { return result; } try { for (const [key, value] of Object.entries(headers)) { // Skip entries with null or undefined values if (value === undefined || value === null) { continue; } // Convert value to string if it's not already const strValue = String(value); const lowerKey = key.toLowerCase(); if (lowerKey === 'authorization') { // Show auth type but redact the actual token const parts = strValue.split(' '); if (parts.length > 1) { const authType = parts[0]; const tokenStart = parts[1].substring(0, 8); result[key] = `${authType} ${tokenStart}...`; } else { result[key] = '[REDACTED]'; } } else if (lowerKey.includes('secret') || lowerKey.includes('password') || lowerKey.includes('token') || lowerKey.includes('key')) { result[key] = '[REDACTED]'; } else { result[key] = strValue; } } } catch (error) { // If anything goes wrong during iteration, return a safe empty object logger.error('Error sanitizing headers', error); } return result; } }