Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,11 @@ DATABASE_URL=postgresql://username:password@localhost:5432/stellarflow?sslmode=r
# Rate Limiting
ADMIN_IP=127.0.0.1

# Webhook Error Reporting
# Discord webhook URL for critical alerts (primary)
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
# Slack webhook URL for critical alerts (fallback)
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
# Rate limit for webhook alerts (minutes)
WEBHOOK_RATE_LIMIT_MINUTES=5

20 changes: 17 additions & 3 deletions src/services/marketRate/coingeckoFetcher.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import axios from "axios";
import { withRetry } from "../../utils/retryUtil.js";
import { createFetcherLogger } from "../../utils/logger.js";

export class CoinGeckoFetcher {
private static readonly API_URL = "https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd";
private static logger = createFetcherLogger("CoinGecko");

/**
* Fetches the current XLM/USD price from CoinGecko.
Expand All @@ -16,8 +18,9 @@ export class CoinGeckoFetcher {
maxRetries: 3,
retryDelay: 1000,
onRetry: (attempt, error, delay) => {
console.debug(
`CoinGecko API retry attempt ${attempt}/3 after ${delay}ms. Error: ${error.message}`
CoinGeckoFetcher.logger.debug(
`API retry attempt ${attempt}/3 after ${delay}ms`,
{ error: error.message, attempt, delay }
);
},
}
Expand All @@ -28,8 +31,19 @@ export class CoinGeckoFetcher {
response.data.stellar &&
typeof response.data.stellar.usd === "number"
) {
CoinGeckoFetcher.logger.info(
`Successfully fetched XLM/USD price`,
{ price: response.data.stellar.usd }
);
return response.data.stellar.usd;
}
throw new Error("Invalid response from CoinGecko API");

const error = new Error("Invalid response from CoinGecko API");
CoinGeckoFetcher.logger.fetcherError(
error,
"API response validation failed",
{ responseData: response.data }
);
throw error;
}
}
26 changes: 18 additions & 8 deletions src/services/marketRate/ngnFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getNGNProviderWeight,
type NGNProviderWeightKey,
} from "../../config/providerWeights.js";
import { createFetcherLogger } from "../../utils/logger.js";

type CoinGeckoPriceResponse = {
stellar?: {
Expand Down Expand Up @@ -71,6 +72,7 @@ export class NGNRateFetcher implements MarketRateFetcher {
"https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=ngn,usd&include_last_updated_at=true";

private readonly usdToNgnUrl = "https://open.er-api.com/v6/latest/USD";
private logger = createFetcherLogger("NGNRate");

private vtpassBase(): string {
return (process.env.VTPASS_API_BASE_URL ?? "https://vtpass.com/api").replace(
Expand Down Expand Up @@ -170,8 +172,8 @@ export class NGNRateFetcher implements MarketRateFetcher {
});
}
}
} catch {
console.debug("VTpass + CoinGecko XLM/USD failed");
} catch (error) {
this.logger.debug("VTpass + CoinGecko XLM/USD failed", { error: error instanceof Error ? error.message : error });
}

try {
Expand Down Expand Up @@ -203,8 +205,8 @@ export class NGNRateFetcher implements MarketRateFetcher {
providerKey: "coinGeckoDirectNgn",
});
}
} catch {
console.debug("CoinGecko direct NGN failed");
} catch (error) {
this.logger.debug("CoinGecko direct NGN failed", { error: error instanceof Error ? error.message : error });
}

try {
Expand Down Expand Up @@ -257,12 +259,18 @@ export class NGNRateFetcher implements MarketRateFetcher {
});
}
}
} catch {
console.debug("CoinGecko + ExchangeRate API (NGN) failed");
} catch (error) {
this.logger.debug("CoinGecko + ExchangeRate API (NGN) failed", { error: error instanceof Error ? error.message : error });
}

if (prices.length === 0) {
throw new Error("All NGN rate sources failed");
const error = new Error("All NGN rate sources failed");
this.logger.fetcherError(
error,
"All price sources failed - no rates obtained",
{ attemptedSources: 3, pricesLength: prices.length }
);
throw error;
}

const filteredRateValues = filterOutliers(
Expand Down Expand Up @@ -296,8 +304,10 @@ export class NGNRateFetcher implements MarketRateFetcher {
async isHealthy(): Promise<boolean> {
try {
const rate = await this.fetchRate();
this.logger.info("Health check passed", { rate: rate.rate, source: rate.source });
return rate.rate > 0;
} catch {
} catch (error) {
this.logger.error("Health check failed", undefined, error instanceof Error ? error : new Error(String(error)));
return false;
}
}
Expand Down
160 changes: 160 additions & 0 deletions src/utils/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { webhookReporter } from './webhookReporter';

export enum LogLevel {
DEBUG = 'debug',
INFO = 'info',
WARN = 'warn',
ERROR = 'error',
CRITICAL = 'critical',
}

interface LogEntry {
level: LogLevel;
message: string;
timestamp: Date;
fetcherName?: string;
error?: Error;
metadata?: Record<string, any>;
}

class Logger {
private serviceName: string;

constructor(serviceName = 'StellarFlow') {
this.serviceName = serviceName;
}

private formatLogEntry(entry: LogEntry): string {
const { level, message, timestamp, fetcherName, error, metadata } = entry;
const timestampStr = timestamp.toISOString();
const fetcherStr = fetcherName ? ` [${fetcherName}]` : '';
const metadataStr = metadata ? ` ${JSON.stringify(metadata)}` : '';
const errorStr = error ? ` Error: ${error.message}` : '';

return `[${timestampStr}] ${level.toUpperCase()}${fetcherStr} ${message}${metadataStr}${errorStr}`;
}

private log(level: LogLevel, message: string, fetcherName?: string, error?: Error, metadata?: Record<string, any>): void {
const entry: LogEntry = {
level,
message,
timestamp: new Date(),
fetcherName,
error,
metadata,
};

const formattedMessage = this.formatLogEntry(entry);

// Output to console
switch (level) {
case LogLevel.DEBUG:
console.debug(formattedMessage);
break;
case LogLevel.INFO:
console.info(formattedMessage);
break;
case LogLevel.WARN:
console.warn(formattedMessage);
break;
case LogLevel.ERROR:
console.error(formattedMessage);
break;
case LogLevel.CRITICAL:
console.error(formattedMessage);
break;
}

// Send webhook alert for critical logs
if (level === LogLevel.CRITICAL && (fetcherName || error)) {
const errorToSend = error || message;
const fetcherToSend = fetcherName || 'Unknown';

// Send webhook asynchronously without blocking
webhookReporter.sendCriticalAlert(errorToSend, fetcherToSend).catch((webhookError) => {
console.error('Failed to send webhook alert:', webhookError instanceof Error ? webhookError.message : webhookError);
});
}
}

public debug(message: string, fetcherName?: string, metadata?: Record<string, any>): void {
this.log(LogLevel.DEBUG, message, fetcherName, undefined, metadata);
}

public info(message: string, fetcherName?: string, metadata?: Record<string, any>): void {
this.log(LogLevel.INFO, message, fetcherName, undefined, metadata);
}

public warn(message: string, fetcherName?: string, metadata?: Record<string, any>): void {
this.log(LogLevel.WARN, message, fetcherName, undefined, metadata);
}

public error(message: string, fetcherName?: string, error?: Error, metadata?: Record<string, any>): void {
this.log(LogLevel.ERROR, message, fetcherName, error, metadata);
}

public critical(message: string, fetcherName?: string, error?: Error, metadata?: Record<string, any>): void {
this.log(LogLevel.CRITICAL, message, fetcherName, error, metadata);
}

// Convenience method for fetcher failures
public fetcherError(fetcherName: string, error: Error, context?: string, metadata?: Record<string, any>): void {
const message = context || `Fetcher ${fetcherName} encountered an error`;
this.critical(message, fetcherName, error, metadata);
}

// Create a logger instance specific to a fetcher
public createFetcherLogger(fetcherName: string): FetcherLogger {
return new FetcherLogger(this, fetcherName);
}
}

// Specialized logger for fetchers
class FetcherLogger {
private parentLogger: Logger;
private fetcherName: string;

constructor(parentLogger: Logger, fetcherName: string) {
this.parentLogger = parentLogger;
this.fetcherName = fetcherName;
}

public debug(message: string, metadata?: Record<string, any>): void {
this.parentLogger.debug(message, this.fetcherName, metadata);
}

public info(message: string, metadata?: Record<string, any>): void {
this.parentLogger.info(message, this.fetcherName, metadata);
}

public warn(message: string, metadata?: Record<string, any>): void {
this.parentLogger.warn(message, this.fetcherName, metadata);
}

public error(message: string, error?: Error, metadata?: Record<string, any>): void {
this.parentLogger.error(message, this.fetcherName, error, metadata);
}

public critical(message: string, error?: Error, metadata?: Record<string, any>): void {
this.parentLogger.critical(message, this.fetcherName, error, metadata);
}

public fetcherError(error: Error, context?: string, metadata?: Record<string, any>): void {
this.parentLogger.fetcherError(this.fetcherName, error, context, metadata);
}

public getFetcherName(): string {
return this.fetcherName;
}
}

// Default logger instance
export const logger = new Logger();

// Export classes for creating custom loggers
export { Logger, FetcherLogger };

// Convenience function to create fetcher loggers
export function createFetcherLogger(fetcherName: string): FetcherLogger {
return logger.createFetcherLogger(fetcherName);
}
Loading
Loading