@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.8 kB
JavaScript
import { createLogger } from '../utils/secure-logger.js';
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) {
const id = refreshId ?? this.activeRefreshId;
if (!id)
return;
// Sanitize headers to remove sensitive information
const sanitizedHeaders = this.sanitizeHeaders(headers);
// Sanitize body for token requests
let sanitizedBody;
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: id,
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) {
const id = refreshId ?? this.activeRefreshId;
if (!id)
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: id,
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) {
const id = refreshId ?? this.activeRefreshId;
if (!id)
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: id,
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 (id === this.activeRefreshId) {
this.activeRefreshId = null;
}
}
/**
* Record failed token refresh
*/
recordRefreshFailure(refreshId, error) {
const id = refreshId ?? this.activeRefreshId;
if (!id)
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: id,
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 (id === 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;
}
}