diff --git a/README.md b/README.md index 5bf727d..97594b0 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,10 @@ stellar-pay/ - **Phase 3**: Anchor Integration (SEP-24) & Compliance Hooks. - **Phase 4**: Admin Dashboard & Event/Webhook Streaming. +## Observability + +Structured JSON logging, request correlation IDs, and CloudWatch dashboard assets live under [docs/observability/README.md](docs/observability/README.md). Use `scripts/observability/deploy-cloudwatch.ps1` to provision the dashboard, metric filters, and alarms for a shared `/stellar-pay/...` log group. + --- ## Contributing diff --git a/apps/api/.env.example b/apps/api/.env.example index f10135c..f874e16 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -17,3 +17,8 @@ DATABASE_URL=postgresql://user:password@localhost:5432/stellar_pay # Redis (for when implemented) REDIS_URL=redis://localhost:6379 + +# Observability +LOG_LEVEL=info +CLOUDWATCH_LOG_GROUP=/stellar-pay/production +CLOUDWATCH_DASHBOARD_NAME=stellar-pay-observability diff --git a/apps/api/package.json b/apps/api/package.json index 578eccd..228f1bf 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,12 +24,12 @@ "@nestjs/core": "^11.0.1", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.2.6", "@nestjs/terminus": "^11.1.1", "@nestjs/throttler": "^6.5.0", "@stellar/stellar-sdk": "^14.6.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", - "@nestjs/swagger": "^11.2.6", "@nestjs/schedule": "^6.1.1", "@stellar-pay/payments-engine": "workspace:*", "cron": "^4.4.0", diff --git a/apps/api/src/app.controller.ts b/apps/api/src/app.controller.ts index 1a467a0..aa549bc 100644 --- a/apps/api/src/app.controller.ts +++ b/apps/api/src/app.controller.ts @@ -1,15 +1,28 @@ import { Body, Controller, Get, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiSecurity, ApiTags } from '@nestjs/swagger'; import { AppService } from './app.service'; +import { Public } from './auth/decorators/public.decorator'; +import { apiLogger } from './observability'; import { HelloRequestDto, HelloResponseDto } from './app.dto'; +const controllerLogger = apiLogger.child({ + controller: 'AppController', +}); + @ApiTags('App') @Controller() export class AppController { constructor(private readonly appService: AppService) {} @Get() + @Public() + @ApiOperation({ summary: 'Get hello message' }) + @ApiResponse({ status: 200, description: 'Return a simple string hello.' }) getHello(): string { + controllerLogger.info('Processing hello endpoint', { + event: 'hello_endpoint_requested', + }); + return this.appService.getHello(); } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 2fa7293..81c2a17 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { ThrottlerModule } from '@nestjs/throttler'; import { AppController } from './app.controller'; @@ -8,6 +8,7 @@ import { TreasuryModule } from './treasury/treasury.module'; import { AuthModule } from './auth/auth.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { ThrottlerRedisGuard } from './rate-limiter/guards/throttler-redis.guard'; +import { RequestContextMiddleware } from './observability'; import { WorkerModule } from './modules/worker/worker.module'; @Module({ @@ -38,4 +39,8 @@ import { WorkerModule } from './modules/worker/worker.module'; }, ], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer): void { + consumer.apply(RequestContextMiddleware).forRoutes('*'); + } +} diff --git a/apps/api/src/app.service.ts b/apps/api/src/app.service.ts index 927d7cc..0a9e51e 100644 --- a/apps/api/src/app.service.ts +++ b/apps/api/src/app.service.ts @@ -1,8 +1,17 @@ import { Injectable } from '@nestjs/common'; +import { apiLogger } from './observability'; + +const serviceLogger = apiLogger.child({ + serviceContext: 'AppService', +}); @Injectable() export class AppService { getHello(): string { + serviceLogger.debug('Returning hello payload', { + event: 'hello_payload_returned', + }); + return 'Hello World!'; } } diff --git a/apps/api/src/health/health.controller.ts b/apps/api/src/health/health.controller.ts index e5ffdf2..c6faf4b 100644 --- a/apps/api/src/health/health.controller.ts +++ b/apps/api/src/health/health.controller.ts @@ -5,6 +5,11 @@ import { DatabaseHealthIndicator } from './indicators/database.health'; import { RedisHealthIndicator } from './indicators/redis.health'; import { BlockchainRpcHealthIndicator } from './indicators/blockchain-rpc.health'; import { TreasuryWalletHealthIndicator } from './indicators/treasury-wallet.health'; +import { apiLogger } from '../observability'; + +const controllerLogger = apiLogger.child({ + controller: 'HealthController', +}); @Controller('health') export class HealthController { @@ -20,6 +25,10 @@ export class HealthController { @Public() @HealthCheck() check(): Promise { + controllerLogger.info('Running health checks', { + event: 'health_check_requested', + }); + return this.health.check([ () => this.database.isHealthy('database'), () => this.redis.isHealthy('redis'), diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 860e561..d137b22 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,9 +1,15 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; +import { apiLogger, LoggingExceptionFilter, RequestLoggingInterceptor } from './observability'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { + logger: false, + }); + + app.useGlobalInterceptors(new RequestLoggingInterceptor()); + app.useGlobalFilters(new LoggingExceptionFilter()); const config = new DocumentBuilder() .setTitle('Stellar Pay API Documentation') @@ -16,6 +22,12 @@ async function bootstrap() { const documentFactory = () => SwaggerModule.createDocument(app, config); SwaggerModule.setup('docs', app, documentFactory); - await app.listen(process.env.PORT ?? 3000); + const port = Number(process.env.PORT ?? 3000); + await app.listen(port); + + apiLogger.info('API bootstrap completed', { + event: 'api_bootstrap_completed', + port, + }); } bootstrap(); diff --git a/apps/api/src/observability/index.ts b/apps/api/src/observability/index.ts new file mode 100644 index 0000000..41a0453 --- /dev/null +++ b/apps/api/src/observability/index.ts @@ -0,0 +1,5 @@ +export * from './logger'; +export * from './logging-exception.filter'; +export * from './request-context'; +export * from './request-context.middleware'; +export * from './request-logging.interceptor'; diff --git a/apps/api/src/observability/logger.spec.ts b/apps/api/src/observability/logger.spec.ts new file mode 100644 index 0000000..05d20ba --- /dev/null +++ b/apps/api/src/observability/logger.spec.ts @@ -0,0 +1,52 @@ +import { StructuredLogger } from './logger'; +import { runWithRequestContext } from './request-context'; + +describe('StructuredLogger', () => { + const originalLogLevel = process.env.LOG_LEVEL; + + afterEach(() => { + process.env.LOG_LEVEL = originalLogLevel; + jest.restoreAllMocks(); + }); + + it('emits structured JSON with the active correlation id', () => { + process.env.LOG_LEVEL = 'debug'; + + const writeSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + const logger = new StructuredLogger({ + serviceName: 'test-service', + }); + + runWithRequestContext({ correlationId: 'corr-123' }, () => { + logger.info('test message', { + event: 'test_event', + route: '/health', + }); + }); + + expect(writeSpy).toHaveBeenCalledTimes(1); + + const payload = JSON.parse(writeSpy.mock.calls[0][0] as string); + expect(payload).toMatchObject({ + service: 'test-service', + message: 'test message', + event: 'test_event', + route: '/health', + correlationId: 'corr-123', + level: 'info', + }); + }); + + it('respects the configured log level', () => { + process.env.LOG_LEVEL = 'error'; + + const writeSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + const logger = new StructuredLogger({ + serviceName: 'test-service', + }); + + logger.info('should not be written'); + + expect(writeSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/observability/logger.ts b/apps/api/src/observability/logger.ts new file mode 100644 index 0000000..9b92f4e --- /dev/null +++ b/apps/api/src/observability/logger.ts @@ -0,0 +1,151 @@ +import { getCorrelationId, getRequestContext } from './request-context'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LOG_LEVEL_PRIORITY: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +export interface StructuredLogEntry { + timestamp: string; + level: LogLevel; + service: string; + message: string; + environment: string; + correlationId?: string; + error?: { + name: string; + message: string; + stack?: string; + }; + [key: string]: unknown; +} + +export type LogMetadata = Record; + +interface LoggerOptions { + serviceName: string; + defaults?: LogMetadata; +} + +export class StructuredLogger { + constructor(private readonly options: LoggerOptions) {} + + child(defaults: LogMetadata): StructuredLogger { + return new StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults, + }, + }); + } + + debug(message: string, metadata: LogMetadata = {}): void { + this.write('debug', message, metadata); + } + + info(message: string, metadata: LogMetadata = {}): void { + this.write('info', message, metadata); + } + + warn(message: string, metadata: LogMetadata = {}): void { + this.write('warn', message, metadata); + } + + error(message: string, metadata: LogMetadata = {}): void { + this.write('error', message, metadata); + } + + private write(level: LogLevel, message: string, metadata: LogMetadata): void { + if (!shouldLog(level)) { + return; + } + + const context = getRequestContext(); + const entry: StructuredLogEntry = { + timestamp: new Date().toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? 'development', + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata), + }; + + Object.keys(entry).forEach((key) => { + if (entry[key] === undefined) { + delete entry[key]; + } + }); + + const payload = JSON.stringify(entry); + + if (level === 'error') { + console.error(payload); + return; + } + + if (level === 'warn') { + console.warn(payload); + return; + } + + console.log(payload); + } +} + +function shouldLog(level: LogLevel): boolean { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} + +function parseLogLevel(value: string | undefined): LogLevel { + if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') { + return value; + } + + return 'info'; +} + +function serializeMetadata(metadata: LogMetadata): LogMetadata { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === undefined) { + return result; + } + + if (key === 'error') { + result.error = serializeError(value); + return result; + } + + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + + result[key] = value; + return result; + }, {}); +} + +function serializeError(error: unknown): StructuredLogEntry['error'] | unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + return error; +} + +export const apiLogger = new StructuredLogger({ + serviceName: 'stellar-pay-api', +}); diff --git a/apps/api/src/observability/logging-exception.filter.ts b/apps/api/src/observability/logging-exception.filter.ts new file mode 100644 index 0000000..e8e3925 --- /dev/null +++ b/apps/api/src/observability/logging-exception.filter.ts @@ -0,0 +1,36 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { apiLogger } from './logger'; + +@Catch() +export class LoggingExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost): void { + const http = host.switchToHttp(); + const response = http.getResponse(); + const request = http.getRequest(); + + const status = + exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; + + const payload = + exception instanceof HttpException + ? exception.getResponse() + : { + message: 'Internal server error', + }; + + apiLogger.error('Unhandled exception', { + event: 'unhandled_exception', + method: request.method, + route: request.originalUrl, + statusCode: status, + correlationId: request.correlationId, + error: exception, + }); + + response.status(status).json({ + ...(typeof payload === 'string' ? { message: payload } : payload), + correlationId: request.correlationId, + }); + } +} diff --git a/apps/api/src/observability/request-context.middleware.ts b/apps/api/src/observability/request-context.middleware.ts new file mode 100644 index 0000000..e86f39f --- /dev/null +++ b/apps/api/src/observability/request-context.middleware.ts @@ -0,0 +1,39 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import type { NextFunction, Request, Response } from 'express'; +import { createCorrelationId, runWithRequestContext } from './request-context'; + +declare module 'express' { + interface Request { + correlationId?: string; + } +} + +@Injectable() +export class RequestContextMiddleware implements NestMiddleware { + use(request: Request, response: Response, next: NextFunction): void { + const correlationId = createCorrelationId( + readHeaderValue(request.headers['x-correlation-id']) ?? + readHeaderValue(request.headers['x-request-id']), + ); + + request.correlationId = correlationId; + response.setHeader('x-correlation-id', correlationId); + + runWithRequestContext( + { + correlationId, + method: request.method, + route: request.originalUrl, + }, + next, + ); + } +} + +function readHeaderValue(header: string | string[] | undefined): string | undefined { + if (Array.isArray(header)) { + return header[0]; + } + + return header; +} diff --git a/apps/api/src/observability/request-context.ts b/apps/api/src/observability/request-context.ts new file mode 100644 index 0000000..5b8f3bc --- /dev/null +++ b/apps/api/src/observability/request-context.ts @@ -0,0 +1,42 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomUUID } from 'node:crypto'; + +export interface ApiRequestContext { + correlationId: string; + requestId?: string; + userId?: string; + route?: string; + method?: string; + [key: string]: unknown; +} + +const storage = new AsyncLocalStorage(); + +export function createCorrelationId(seed?: string): string { + const value = seed?.trim(); + return value && value.length > 0 ? value : randomUUID(); +} + +export function runWithRequestContext( + context: Partial, + callback: () => T, +): T { + const current = storage.getStore(); + + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId), + }, + callback, + ); +} + +export function getRequestContext(): ApiRequestContext | undefined { + return storage.getStore(); +} + +export function getCorrelationId(): string | undefined { + return storage.getStore()?.correlationId; +} diff --git a/apps/api/src/observability/request-logging.interceptor.ts b/apps/api/src/observability/request-logging.interceptor.ts new file mode 100644 index 0000000..71cee13 --- /dev/null +++ b/apps/api/src/observability/request-logging.interceptor.ts @@ -0,0 +1,45 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import type { Request } from 'express'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { apiLogger } from './logger'; + +@Injectable() +export class RequestLoggingInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const http = context.switchToHttp(); + const request = http.getRequest< + Request & { correlationId?: string; user?: { sub?: string } } + >(); + const response = http.getResponse<{ statusCode?: number }>(); + const start = Date.now(); + + return next.handle().pipe( + tap({ + next: () => { + apiLogger.info('HTTP request completed', { + event: 'http_request_completed', + method: request.method, + route: request.originalUrl, + statusCode: response.statusCode, + durationMs: Date.now() - start, + correlationId: request.correlationId, + userId: request.user?.sub, + }); + }, + error: (error: unknown) => { + apiLogger.error('HTTP request failed', { + event: 'http_request_failed', + method: request.method, + route: request.originalUrl, + statusCode: response.statusCode, + durationMs: Date.now() - start, + correlationId: request.correlationId, + userId: request.user?.sub, + error, + }); + }, + }), + ); + } +} diff --git a/apps/api/src/treasury/treasury.controller.ts b/apps/api/src/treasury/treasury.controller.ts index 65b2d62..6158d29 100644 --- a/apps/api/src/treasury/treasury.controller.ts +++ b/apps/api/src/treasury/treasury.controller.ts @@ -1,6 +1,11 @@ import { Controller, Get } from '@nestjs/common'; import { TreasuryService } from './treasury.service'; import { ProofOfReservesResponse } from './interfaces/proof-of-reserves.interface'; +import { apiLogger } from '../observability'; + +const controllerLogger = apiLogger.child({ + controller: 'TreasuryController', +}); @Controller('treasury') export class TreasuryController { @@ -12,6 +17,11 @@ export class TreasuryController { // const supportedAssets = await this.configService.getSupportedAssets(); const supportedAssets = (process.env.SUPPORTED_ASSETS ?? 'USDC,ARS').split(','); + controllerLogger.info('Generating proof of reserves snapshot', { + event: 'treasury_proof_of_reserves_requested', + supportedAssets: supportedAssets.map((asset) => asset.trim()), + }); + const reserves = await Promise.all( supportedAssets.map((asset) => this.treasuryService.getAssetReserve(asset.trim())), ); diff --git a/apps/api/src/treasury/treasury.service.ts b/apps/api/src/treasury/treasury.service.ts index 58f640c..5d2350a 100644 --- a/apps/api/src/treasury/treasury.service.ts +++ b/apps/api/src/treasury/treasury.service.ts @@ -1,5 +1,10 @@ import { Injectable } from '@nestjs/common'; import { AssetReserve } from './interfaces/proof-of-reserves.interface'; +import { apiLogger } from '../observability'; + +const serviceLogger = apiLogger.child({ + serviceContext: 'TreasuryService', +}); @Injectable() export class TreasuryService { @@ -13,7 +18,10 @@ export class TreasuryService { // const balance = acc.balances.find((b: any) => b.asset_code === assetCode); // return sum + (balance ? parseFloat(balance.balance) : 0); // }, 0).toString(); - + serviceLogger.debug('Fetching total supply placeholder response', { + event: 'treasury_total_supply_requested', + assetCode: _assetCode, + }); return '0'; } @@ -24,7 +32,11 @@ export class TreasuryService { // const account = await horizon.loadAccount(treasuryAddress); // const balance = account.balances.find((b: any) => b.asset_code === assetCode); // return balance?.balance ?? '0'; - + serviceLogger.debug('Fetching treasury balance placeholder response', { + event: 'treasury_balance_requested', + assetCode: _assetCode, + treasuryAddress: _treasuryAddress, + }); return '0'; } @@ -42,6 +54,13 @@ export class TreasuryService { // const treasuryAddress = await this.configService.getTreasuryAddress(); const treasuryAddress = process.env.TREASURY_WALLET_ADDRESS ?? 'TREASURY_ADDRESS_NOT_SET'; + if (treasuryAddress === 'TREASURY_ADDRESS_NOT_SET') { + serviceLogger.warn('Treasury wallet address missing, using placeholder', { + event: 'treasury_wallet_address_missing', + assetCode, + }); + } + const [totalSupply, treasuryBalance] = await Promise.all([ this.getTotalSupply(assetCode), this.getTreasuryBalance(assetCode, treasuryAddress), @@ -49,6 +68,14 @@ export class TreasuryService { const reserveRatio = this.calculateReserveRatio(treasuryBalance, totalSupply); + serviceLogger.info('Computed asset reserve snapshot', { + event: 'treasury_asset_reserve_computed', + assetCode, + totalSupply, + treasuryBalance, + reserveRatio, + }); + return { symbol: assetCode, total_supply: totalSupply, diff --git a/apps/frontend/src/app/dashboard/audit-logs/page.tsx b/apps/frontend/src/app/dashboard/audit-logs/page.tsx index 6b3c242..1df9cb1 100644 --- a/apps/frontend/src/app/dashboard/audit-logs/page.tsx +++ b/apps/frontend/src/app/dashboard/audit-logs/page.tsx @@ -1,125 +1,303 @@ 'use client'; -import { motion } from "motion/react"; -import { Filter, Download, Activity } from "lucide-react"; - -const auditLogs = [ - { timestamp: "2026-03-03 14:32:15", action: "API Key Created", actor: "admin@acmecorp.com", ip: "192.168.1.100", details: "Created sk_live_***", status: "success" }, - { timestamp: "2026-03-03 14:28:42", action: "Payment Initiated", actor: "api-service", ip: "10.0.1.50", details: "pay_9k2j3n4k5j6h", status: "success" }, - { timestamp: "2026-03-03 14:24:08", action: "Webhook Updated", actor: "admin@acmecorp.com", ip: "192.168.1.100", details: "wh_1a2b3c", status: "success" }, - { timestamp: "2026-03-03 14:15:22", action: "Login Attempt", actor: "admin@acmecorp.com", ip: "192.168.1.100", details: "2FA verified", status: "success" }, - { timestamp: "2026-03-03 13:58:45", action: "Treasury Redemption", actor: "api-service", ip: "10.0.1.50", details: "5,000.00 sUSDC", status: "success" }, - { timestamp: "2026-03-03 13:42:18", action: "Login Attempt", actor: "unknown@suspicious.com", ip: "185.220.101.52", details: "Failed authentication", status: "failed" }, - { timestamp: "2026-03-03 12:15:30", action: "Compliance Document Uploaded", actor: "admin@acmecorp.com", ip: "192.168.1.100", details: "Banking Information", status: "success" }, - { timestamp: "2026-03-03 11:32:45", action: "Escrow Created", actor: "api-service", ip: "10.0.1.50", details: "esc_1a2b3c", status: "success" }, +import { motion } from 'motion/react'; +import { Activity, BellRing, Cloud, Search, ShieldAlert, Waypoints } from 'lucide-react'; + +const summaryCards = [ + { + label: 'Events / 24h', + value: '15,247', + detail: 'CloudWatch log group /stellar-pay/production', + }, + { + label: 'Correlated Requests', + value: '98.3%', + detail: 'Matched by x-correlation-id across services', + }, + { label: 'Active Alerts', value: '2', detail: 'Error spike and high latency alarms provisioned' }, + { + label: 'Slow Requests', + value: '17', + detail: 'Requests above the 2s threshold in the last hour', + }, +]; + +const serviceRollup = [ + { service: 'stellar-pay-api', volume: '8,913', status: 'Healthy' }, + { service: '@stellar-pay/payments-engine', volume: '2,784', status: 'Healthy' }, + { service: '@stellar-pay/compliance-engine', volume: '1,966', status: 'Investigate' }, + { service: '@stellar-pay/subscriptions', volume: '1,584', status: 'Healthy' }, +]; + +const alerts = [ + { + name: 'stellar-pay-error-spike', + state: 'Armed', + threshold: '>= 5 errors / 5 min', + action: 'SNS notification', + }, + { + name: 'stellar-pay-high-latency', + state: 'Armed', + threshold: '>= 3 slow requests / 5 min', + action: 'SNS notification', + }, +]; + +const logs = [ + { + timestamp: '2026-03-24 18:21:43', + service: 'stellar-pay-api', + level: 'info', + correlationId: 'req_8b0d2f80d761', + route: 'GET /treasury/reserves', + message: 'HTTP request completed', + latency: '184ms', + }, + { + timestamp: '2026-03-24 18:21:43', + service: '@stellar-pay/payments-engine', + level: 'info', + correlationId: 'req_8b0d2f80d761', + route: 'reserve-calculation', + message: 'Computed asset reserve snapshot', + latency: '141ms', + }, + { + timestamp: '2026-03-24 18:20:02', + service: 'stellar-pay-api', + level: 'warn', + correlationId: 'req_61d5eb9c0f25', + route: 'GET /treasury/reserves', + message: 'Treasury wallet address missing, using placeholder', + latency: '-', + }, + { + timestamp: '2026-03-24 18:18:27', + service: '@stellar-pay/compliance-engine', + level: 'error', + correlationId: 'req_d0a77d1cb4aa', + route: 'transaction-screening', + message: 'HTTP request failed', + latency: '2.8s', + }, + { + timestamp: '2026-03-24 18:15:04', + service: '@stellar-pay/subscriptions', + level: 'info', + correlationId: 'req_7f90da2814d8', + route: 'subscription-renewal', + message: 'JSON payload forwarded to CloudWatch', + latency: '96ms', + }, +]; + +const queries = [ + { + label: 'Recent correlated requests', + snippet: + 'fields @timestamp, service, level, correlationId, route, statusCode, durationMs, message | sort @timestamp desc | limit 50', + }, + { + label: 'Trace a single request', + snippet: + 'fields @timestamp, service, level, correlationId, message, error.message | filter correlationId = "" | sort @timestamp asc', + }, + { + label: 'Slow requests', + snippet: + 'fields @timestamp, route, durationMs, correlationId, statusCode | filter event = "http_request_completed" and durationMs >= 2000 | sort @timestamp desc', + }, ]; export default function AuditLogsPage() { return (
-
- +
+ + Aggregated Log Viewer + +

+ Structured JSON logs, request correlation IDs, CloudWatch dashboards, and alert + thresholds in one place. +

+
+ + - Audit Logs -
-

Complete activity history with tamper-proof logging

+ + CloudWatch integration ready +
-
- {[ - { label: "Total Events", value: "15,247" }, - { label: "Today", value: "342" }, - { label: "Failed Actions", value: "1" }, - { label: "Security Alerts", value: "0" }, - ].map((stat, index) => ( +
+ {summaryCards.map((card, index) => ( -
{stat.value}
-
{stat.label}
+
{card.label}
+
{card.value}
+
{card.detail}
))}
+
+ +
+ + Service Rollup +
+ +
+ {serviceRollup.map((service) => ( +
+
+
{service.service}
+
+ {service.volume} events in the last 24h +
+
+ + {service.status} + +
+ ))} +
+
+ + +
+ + Alert Policies +
+ +
+ {alerts.map((alert) => ( +
+
+
{alert.name}
+ + {alert.state} + +
+
{alert.threshold}
+
{alert.action}
+
+ ))} +
+
+
+ -
- - Live monitoring active +
+ + Logs Insights Queries +
+ +
+ {queries.map((query) => ( +
+
{query.label}
+ {query.snippet} +
+ ))}
-
- - +
+
+ + Recent Aggregated Events +
+
+ + Correlation IDs retained in every request path +
+
+
- - - - - - - + + + + + + + + - {auditLogs.map((log, index) => ( + {logs.map((log, index) => ( - - - - - - + + + + + + ))} diff --git a/apps/frontend/src/app/dashboard/page.tsx b/apps/frontend/src/app/dashboard/page.tsx index ef7b64e..8dbff9c 100644 --- a/apps/frontend/src/app/dashboard/page.tsx +++ b/apps/frontend/src/app/dashboard/page.tsx @@ -44,9 +44,15 @@ const stats = [ ]; const assets = [ - { symbol: 'sUSDC', balance: '1,245,382.45', usd: '1,245,382.45', change: '+2.3%' }, - { symbol: 'sBTC', balance: '12.4583', usd: '625,847.92', change: '+5.1%' }, - { symbol: 'sETH', balance: '145.2341', usd: '232,251.75', change: '-1.2%' }, + { + symbol: 'sUSDC', + balance: '1,245,382.45', + usd: '1,245,382.45', + change: '+2.3%', + barWidth: '95%', + }, + { symbol: 'sBTC', balance: '12.4583', usd: '625,847.92', change: '+5.1%', barWidth: '78%' }, + { symbol: 'sETH', balance: '145.2341', usd: '232,251.75', change: '-1.2%', barWidth: '62%' }, ]; const transactions = [ @@ -211,7 +217,7 @@ export default function OverviewPage() { diff --git a/docs/observability/README.md b/docs/observability/README.md new file mode 100644 index 0000000..2fe69e2 --- /dev/null +++ b/docs/observability/README.md @@ -0,0 +1,65 @@ +# CloudWatch Observability + +This setup standardizes structured JSON logs for the API and workspace services, then aggregates them in Amazon CloudWatch. + +## What Ships In This Repo + +- JSON loggers in every package under `packages/*/src/logger.ts` +- API request correlation IDs via `x-correlation-id` +- CloudWatch dashboard definition in `cloudwatch-dashboard.json` +- CloudWatch metric filters in `cloudwatch-metric-filters.json` +- CloudWatch alarms in `cloudwatch-alarms.json` +- Deployment helper in `scripts/observability/deploy-cloudwatch.ps1` + +## Logging Contract + +Every emitted log line is JSON and includes these fields when available: + +- `timestamp` +- `level` +- `service` +- `message` +- `correlationId` +- `event` +- `route` +- `statusCode` +- `durationMs` +- `error` + +Forward stdout/stderr from each runtime into the same CloudWatch log group, for example `/stellar-pay/production`. This repo assumes a shared log group and service-level differentiation through the `service` field. + +## Deploy + +Run from the repo root with AWS credentials that can manage CloudWatch resources: + +```powershell +./scripts/observability/deploy-cloudwatch.ps1 -Region us-east-1 -LogGroup /stellar-pay/production -DashboardName stellar-pay-observability -AlarmTopicArn arn:aws:sns:us-east-1:123456789012:stellar-pay-alerts +``` + +The script creates the log group if needed, applies the retention policy, provisions metric filters, uploads the dashboard, and configures the alarms. + +## Log Viewer Queries + +Recent correlated requests: + +```text +fields @timestamp, service, level, correlationId, route, statusCode, durationMs, message +| sort @timestamp desc +| limit 50 +``` + +Errors for a specific request trace: + +```text +fields @timestamp, service, level, correlationId, message, error.message +| filter correlationId = "" +| sort @timestamp asc +``` + +Slow requests over 2 seconds: + +```text +fields @timestamp, route, durationMs, correlationId, statusCode +| filter event = "http_request_completed" and durationMs >= 2000 +| sort @timestamp desc +``` diff --git a/docs/observability/cloudwatch-alarms.json b/docs/observability/cloudwatch-alarms.json new file mode 100644 index 0000000..6efca6a --- /dev/null +++ b/docs/observability/cloudwatch-alarms.json @@ -0,0 +1,40 @@ +[ + { + "AlarmName": "stellar-pay-error-spike", + "AlarmDescription": "Triggers when error logs spike above the tolerated threshold.", + "Namespace": "StellarPay/Observability", + "MetricName": "ErrorCount", + "Dimensions": [ + { + "Name": "LogGroup", + "Value": "__LOG_GROUP__" + } + ], + "Statistic": "Sum", + "Period": 300, + "EvaluationPeriods": 1, + "DatapointsToAlarm": 1, + "Threshold": 5, + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "TreatMissingData": "notBreaching" + }, + { + "AlarmName": "stellar-pay-high-latency", + "AlarmDescription": "Triggers when slow requests persist across the aggregation window.", + "Namespace": "StellarPay/Observability", + "MetricName": "HighLatencyRequestCount", + "Dimensions": [ + { + "Name": "LogGroup", + "Value": "__LOG_GROUP__" + } + ], + "Statistic": "Sum", + "Period": 300, + "EvaluationPeriods": 1, + "DatapointsToAlarm": 1, + "Threshold": 3, + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "TreatMissingData": "notBreaching" + } +] diff --git a/docs/observability/cloudwatch-dashboard.json b/docs/observability/cloudwatch-dashboard.json new file mode 100644 index 0000000..04ed8b8 --- /dev/null +++ b/docs/observability/cloudwatch-dashboard.json @@ -0,0 +1,78 @@ +{ + "widgets": [ + { + "type": "log", + "width": 24, + "height": 8, + "x": 0, + "y": 0, + "properties": { + "title": "Recent Correlated Requests", + "region": "__REGION__", + "view": "table", + "query": "SOURCE '__LOG_GROUP__' | fields @timestamp, service, level, correlationId, route, statusCode, durationMs, message | sort @timestamp desc | limit 50" + } + }, + { + "type": "log", + "width": 12, + "height": 8, + "x": 0, + "y": 8, + "properties": { + "title": "Errors by Service", + "region": "__REGION__", + "view": "timeSeries", + "query": "SOURCE '__LOG_GROUP__' | filter level = 'error' | stats count() as errors by bin(5m), service" + } + }, + { + "type": "log", + "width": 12, + "height": 8, + "x": 12, + "y": 8, + "properties": { + "title": "P95 Latency", + "region": "__REGION__", + "view": "timeSeries", + "query": "SOURCE '__LOG_GROUP__' | filter event = 'http_request_completed' | stats pct(durationMs, 95) as p95LatencyMs by bin(5m)" + } + }, + { + "type": "metric", + "width": 12, + "height": 6, + "x": 0, + "y": 16, + "properties": { + "title": "Error and Warning Counts", + "region": "__REGION__", + "view": "timeSeries", + "stat": "Sum", + "period": 300, + "metrics": [ + ["StellarPay/Observability", "ErrorCount", "LogGroup", "__LOG_GROUP__"], + [".", "WarningCount", ".", "."] + ] + } + }, + { + "type": "metric", + "width": 12, + "height": 6, + "x": 12, + "y": 16, + "properties": { + "title": "High Latency Request Count", + "region": "__REGION__", + "view": "singleValue", + "stat": "Sum", + "period": 300, + "metrics": [ + ["StellarPay/Observability", "HighLatencyRequestCount", "LogGroup", "__LOG_GROUP__"] + ] + } + } + ] +} diff --git a/docs/observability/cloudwatch-metric-filters.json b/docs/observability/cloudwatch-metric-filters.json new file mode 100644 index 0000000..4606abc --- /dev/null +++ b/docs/observability/cloudwatch-metric-filters.json @@ -0,0 +1,47 @@ +[ + { + "filterName": "stellar-pay-error-count", + "filterPattern": "{ $.level = \"error\" }", + "metricTransformations": [ + { + "metricName": "ErrorCount", + "metricNamespace": "StellarPay/Observability", + "metricValue": "1", + "defaultValue": 0, + "dimensions": { + "LogGroup": "__LOG_GROUP__" + } + } + ] + }, + { + "filterName": "stellar-pay-warning-count", + "filterPattern": "{ $.level = \"warn\" }", + "metricTransformations": [ + { + "metricName": "WarningCount", + "metricNamespace": "StellarPay/Observability", + "metricValue": "1", + "defaultValue": 0, + "dimensions": { + "LogGroup": "__LOG_GROUP__" + } + } + ] + }, + { + "filterName": "stellar-pay-high-latency-count", + "filterPattern": "{ $.event = \"http_request_completed\" && $.durationMs >= 2000 }", + "metricTransformations": [ + { + "metricName": "HighLatencyRequestCount", + "metricNamespace": "StellarPay/Observability", + "metricValue": "1", + "defaultValue": 0, + "dimensions": { + "LogGroup": "__LOG_GROUP__" + } + } + ] + } +] diff --git a/package.json b/package.json index a85cc22..747578e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ ], "devDependencies": { "@eslint/js": "^9.39.3", + "@types/node": "22.10.7", "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.56.1", "eslint": "^9.39.3", diff --git a/packages/anchor-service/dist/index.js b/packages/anchor-service/dist/index.js index 3918c74..98c0b09 100644 --- a/packages/anchor-service/dist/index.js +++ b/packages/anchor-service/dist/index.js @@ -1 +1,191 @@ "use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/index.ts +var index_exports = {}; +__export(index_exports, { + StructuredLogger: () => StructuredLogger, + createCorrelationId: () => createCorrelationId, + createLogger: () => createLogger, + getCorrelationId: () => getCorrelationId, + getRequestContext: () => getRequestContext, + logger: () => logger, + runWithCorrelationId: () => runWithCorrelationId, + runWithRequestContext: () => runWithRequestContext +}); +module.exports = __toCommonJS(index_exports); + +// src/request-context.ts +var import_node_async_hooks = require("async_hooks"); +var import_node_crypto = require("crypto"); +var storage = new import_node_async_hooks.AsyncLocalStorage(); +function createCorrelationId(seed) { + const value = seed?.trim(); + return value && value.length > 0 ? value : (0, import_node_crypto.randomUUID)(); +} +function runWithRequestContext(context, callback) { + const current = storage.getStore(); + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId) + }, + callback + ); +} +function runWithCorrelationId(correlationId, callback) { + return runWithRequestContext({ correlationId }, callback); +} +function getRequestContext() { + return storage.getStore(); +} +function getCorrelationId() { + return storage.getStore()?.correlationId; +} + +// src/logger.ts +var LOG_LEVEL_PRIORITY = { + debug: 10, + info: 20, + warn: 30, + error: 40 +}; +var ConsoleJsonTransport = class { + write(entry) { + const serialized = JSON.stringify(entry); + if (entry.level === "error") { + console.error(serialized); + return; + } + if (entry.level === "warn") { + console.warn(serialized); + return; + } + console.log(serialized); + } +}; +var StructuredLogger = class _StructuredLogger { + constructor(options) { + this.options = options; + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + transport; + child(defaults) { + return new _StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults + }, + transport: this.transport + }); + } + debug(message, metadata = {}) { + this.log("debug", message, metadata); + } + info(message, metadata = {}) { + this.log("info", message, metadata); + } + warn(message, metadata = {}) { + this.log("warn", message, metadata); + } + error(message, metadata = {}) { + this.log("error", message, metadata); + } + log(level, message, metadata) { + if (!shouldLog(level)) { + return; + } + const context = getRequestContext(); + const entry = { + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? "development", + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata) + }; + Object.keys(entry).forEach((key) => { + if (entry[key] === void 0) { + delete entry[key]; + } + }); + this.transport.write(entry); + } +}; +function shouldLog(level) { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} +function parseLogLevel(value) { + if (value === "debug" || value === "info" || value === "warn" || value === "error") { + return value; + } + return "info"; +} +function serializeMetadata(metadata) { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === void 0) { + return result; + } + if (key === "error") { + result.error = serializeError(value); + return result; + } + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + result[key] = value; + return result; + }, {}); +} +function serializeError(error) { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack + }; + } + return error; +} +function createLogger(options = {}) { + return new StructuredLogger({ + serviceName: options.serviceName ?? "@stellar-pay/anchor-service", + transport: options.transport, + defaults: options.defaults + }); +} +var logger = createLogger({ serviceName: "@stellar-pay/anchor-service" }); +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + StructuredLogger, + createCorrelationId, + createLogger, + getCorrelationId, + getRequestContext, + logger, + runWithCorrelationId, + runWithRequestContext +}); diff --git a/packages/anchor-service/dist/index.mjs b/packages/anchor-service/dist/index.mjs index e69de29..950dd75 100644 --- a/packages/anchor-service/dist/index.mjs +++ b/packages/anchor-service/dist/index.mjs @@ -0,0 +1,157 @@ +// src/request-context.ts +import { AsyncLocalStorage } from "async_hooks"; +import { randomUUID } from "crypto"; +var storage = new AsyncLocalStorage(); +function createCorrelationId(seed) { + const value = seed?.trim(); + return value && value.length > 0 ? value : randomUUID(); +} +function runWithRequestContext(context, callback) { + const current = storage.getStore(); + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId) + }, + callback + ); +} +function runWithCorrelationId(correlationId, callback) { + return runWithRequestContext({ correlationId }, callback); +} +function getRequestContext() { + return storage.getStore(); +} +function getCorrelationId() { + return storage.getStore()?.correlationId; +} + +// src/logger.ts +var LOG_LEVEL_PRIORITY = { + debug: 10, + info: 20, + warn: 30, + error: 40 +}; +var ConsoleJsonTransport = class { + write(entry) { + const serialized = JSON.stringify(entry); + if (entry.level === "error") { + console.error(serialized); + return; + } + if (entry.level === "warn") { + console.warn(serialized); + return; + } + console.log(serialized); + } +}; +var StructuredLogger = class _StructuredLogger { + constructor(options) { + this.options = options; + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + transport; + child(defaults) { + return new _StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults + }, + transport: this.transport + }); + } + debug(message, metadata = {}) { + this.log("debug", message, metadata); + } + info(message, metadata = {}) { + this.log("info", message, metadata); + } + warn(message, metadata = {}) { + this.log("warn", message, metadata); + } + error(message, metadata = {}) { + this.log("error", message, metadata); + } + log(level, message, metadata) { + if (!shouldLog(level)) { + return; + } + const context = getRequestContext(); + const entry = { + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? "development", + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata) + }; + Object.keys(entry).forEach((key) => { + if (entry[key] === void 0) { + delete entry[key]; + } + }); + this.transport.write(entry); + } +}; +function shouldLog(level) { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} +function parseLogLevel(value) { + if (value === "debug" || value === "info" || value === "warn" || value === "error") { + return value; + } + return "info"; +} +function serializeMetadata(metadata) { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === void 0) { + return result; + } + if (key === "error") { + result.error = serializeError(value); + return result; + } + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + result[key] = value; + return result; + }, {}); +} +function serializeError(error) { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack + }; + } + return error; +} +function createLogger(options = {}) { + return new StructuredLogger({ + serviceName: options.serviceName ?? "@stellar-pay/anchor-service", + transport: options.transport, + defaults: options.defaults + }); +} +var logger = createLogger({ serviceName: "@stellar-pay/anchor-service" }); +export { + StructuredLogger, + createCorrelationId, + createLogger, + getCorrelationId, + getRequestContext, + logger, + runWithCorrelationId, + runWithRequestContext +}; diff --git a/packages/anchor-service/src/index.ts b/packages/anchor-service/src/index.ts index e69de29..92c0130 100644 --- a/packages/anchor-service/src/index.ts +++ b/packages/anchor-service/src/index.ts @@ -0,0 +1,2 @@ +export * from './logger'; +export * from './request-context'; diff --git a/packages/anchor-service/src/logger.ts b/packages/anchor-service/src/logger.ts new file mode 100644 index 0000000..221c9c1 --- /dev/null +++ b/packages/anchor-service/src/logger.ts @@ -0,0 +1,173 @@ +import { getCorrelationId, getRequestContext } from './request-context'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LOG_LEVEL_PRIORITY: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +export interface LogTransport { + write(entry: StructuredLogEntry): void; +} + +export interface StructuredLogEntry { + timestamp: string; + level: LogLevel; + service: string; + message: string; + environment: string; + correlationId?: string; + error?: { + name: string; + message: string; + stack?: string; + }; + [key: string]: unknown; +} + +export type LogMetadata = Record; + +interface LoggerOptions { + serviceName: string; + transport?: LogTransport; + defaults?: LogMetadata; +} + +class ConsoleJsonTransport implements LogTransport { + write(entry: StructuredLogEntry): void { + const serialized = JSON.stringify(entry); + + if (entry.level === 'error') { + console.error(serialized); + return; + } + + if (entry.level === 'warn') { + console.warn(serialized); + return; + } + + console.log(serialized); + } +} + +export class StructuredLogger { + private readonly transport: LogTransport; + + constructor(private readonly options: LoggerOptions) { + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + + child(defaults: LogMetadata): StructuredLogger { + return new StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults, + }, + transport: this.transport, + }); + } + + debug(message: string, metadata: LogMetadata = {}): void { + this.log('debug', message, metadata); + } + + info(message: string, metadata: LogMetadata = {}): void { + this.log('info', message, metadata); + } + + warn(message: string, metadata: LogMetadata = {}): void { + this.log('warn', message, metadata); + } + + error(message: string, metadata: LogMetadata = {}): void { + this.log('error', message, metadata); + } + + private log(level: LogLevel, message: string, metadata: LogMetadata): void { + if (!shouldLog(level)) { + return; + } + + const context = getRequestContext(); + const entry: StructuredLogEntry = { + timestamp: new Date().toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? 'development', + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata), + }; + + Object.keys(entry).forEach((key) => { + if (entry[key] === undefined) { + delete entry[key]; + } + }); + + this.transport.write(entry); + } +} + +function shouldLog(level: LogLevel): boolean { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} + +function parseLogLevel(value: string | undefined): LogLevel { + if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') { + return value; + } + + return 'info'; +} + +function serializeMetadata(metadata: LogMetadata): LogMetadata { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === undefined) { + return result; + } + + if (key === 'error') { + result.error = serializeError(value); + return result; + } + + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + + result[key] = value; + return result; + }, {}); +} + +function serializeError(error: unknown): StructuredLogEntry['error'] | unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + return error; +} + +export function createLogger(options: Partial = {}): StructuredLogger { + return new StructuredLogger({ + serviceName: options.serviceName ?? '@stellar-pay/anchor-service', + transport: options.transport, + defaults: options.defaults, + }); +} + +export const logger = createLogger({ serviceName: '@stellar-pay/anchor-service' }); diff --git a/packages/anchor-service/src/request-context.ts b/packages/anchor-service/src/request-context.ts new file mode 100644 index 0000000..9d7cd4a --- /dev/null +++ b/packages/anchor-service/src/request-context.ts @@ -0,0 +1,42 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomUUID } from 'node:crypto'; + +export interface RequestContext { + correlationId: string; + requestId?: string; + traceId?: string; + userId?: string; + [key: string]: unknown; +} + +const storage = new AsyncLocalStorage(); + +export function createCorrelationId(seed?: string): string { + const value = seed?.trim(); + return value && value.length > 0 ? value : randomUUID(); +} + +export function runWithRequestContext(context: Partial, callback: () => T): T { + const current = storage.getStore(); + + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId), + }, + callback, + ); +} + +export function runWithCorrelationId(correlationId: string, callback: () => T): T { + return runWithRequestContext({ correlationId }, callback); +} + +export function getRequestContext(): RequestContext | undefined { + return storage.getStore(); +} + +export function getCorrelationId(): string | undefined { + return storage.getStore()?.correlationId; +} diff --git a/packages/compliance-engine/dist/index.js b/packages/compliance-engine/dist/index.js index 3918c74..0d9b4e8 100644 --- a/packages/compliance-engine/dist/index.js +++ b/packages/compliance-engine/dist/index.js @@ -1 +1,191 @@ "use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/index.ts +var index_exports = {}; +__export(index_exports, { + StructuredLogger: () => StructuredLogger, + createCorrelationId: () => createCorrelationId, + createLogger: () => createLogger, + getCorrelationId: () => getCorrelationId, + getRequestContext: () => getRequestContext, + logger: () => logger, + runWithCorrelationId: () => runWithCorrelationId, + runWithRequestContext: () => runWithRequestContext +}); +module.exports = __toCommonJS(index_exports); + +// src/request-context.ts +var import_node_async_hooks = require("async_hooks"); +var import_node_crypto = require("crypto"); +var storage = new import_node_async_hooks.AsyncLocalStorage(); +function createCorrelationId(seed) { + const value = seed?.trim(); + return value && value.length > 0 ? value : (0, import_node_crypto.randomUUID)(); +} +function runWithRequestContext(context, callback) { + const current = storage.getStore(); + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId) + }, + callback + ); +} +function runWithCorrelationId(correlationId, callback) { + return runWithRequestContext({ correlationId }, callback); +} +function getRequestContext() { + return storage.getStore(); +} +function getCorrelationId() { + return storage.getStore()?.correlationId; +} + +// src/logger.ts +var LOG_LEVEL_PRIORITY = { + debug: 10, + info: 20, + warn: 30, + error: 40 +}; +var ConsoleJsonTransport = class { + write(entry) { + const serialized = JSON.stringify(entry); + if (entry.level === "error") { + console.error(serialized); + return; + } + if (entry.level === "warn") { + console.warn(serialized); + return; + } + console.log(serialized); + } +}; +var StructuredLogger = class _StructuredLogger { + constructor(options) { + this.options = options; + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + transport; + child(defaults) { + return new _StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults + }, + transport: this.transport + }); + } + debug(message, metadata = {}) { + this.log("debug", message, metadata); + } + info(message, metadata = {}) { + this.log("info", message, metadata); + } + warn(message, metadata = {}) { + this.log("warn", message, metadata); + } + error(message, metadata = {}) { + this.log("error", message, metadata); + } + log(level, message, metadata) { + if (!shouldLog(level)) { + return; + } + const context = getRequestContext(); + const entry = { + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? "development", + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata) + }; + Object.keys(entry).forEach((key) => { + if (entry[key] === void 0) { + delete entry[key]; + } + }); + this.transport.write(entry); + } +}; +function shouldLog(level) { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} +function parseLogLevel(value) { + if (value === "debug" || value === "info" || value === "warn" || value === "error") { + return value; + } + return "info"; +} +function serializeMetadata(metadata) { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === void 0) { + return result; + } + if (key === "error") { + result.error = serializeError(value); + return result; + } + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + result[key] = value; + return result; + }, {}); +} +function serializeError(error) { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack + }; + } + return error; +} +function createLogger(options = {}) { + return new StructuredLogger({ + serviceName: options.serviceName ?? "@stellar-pay/compliance-engine", + transport: options.transport, + defaults: options.defaults + }); +} +var logger = createLogger({ serviceName: "@stellar-pay/compliance-engine" }); +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + StructuredLogger, + createCorrelationId, + createLogger, + getCorrelationId, + getRequestContext, + logger, + runWithCorrelationId, + runWithRequestContext +}); diff --git a/packages/compliance-engine/dist/index.mjs b/packages/compliance-engine/dist/index.mjs index e69de29..77d4e87 100644 --- a/packages/compliance-engine/dist/index.mjs +++ b/packages/compliance-engine/dist/index.mjs @@ -0,0 +1,157 @@ +// src/request-context.ts +import { AsyncLocalStorage } from "async_hooks"; +import { randomUUID } from "crypto"; +var storage = new AsyncLocalStorage(); +function createCorrelationId(seed) { + const value = seed?.trim(); + return value && value.length > 0 ? value : randomUUID(); +} +function runWithRequestContext(context, callback) { + const current = storage.getStore(); + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId) + }, + callback + ); +} +function runWithCorrelationId(correlationId, callback) { + return runWithRequestContext({ correlationId }, callback); +} +function getRequestContext() { + return storage.getStore(); +} +function getCorrelationId() { + return storage.getStore()?.correlationId; +} + +// src/logger.ts +var LOG_LEVEL_PRIORITY = { + debug: 10, + info: 20, + warn: 30, + error: 40 +}; +var ConsoleJsonTransport = class { + write(entry) { + const serialized = JSON.stringify(entry); + if (entry.level === "error") { + console.error(serialized); + return; + } + if (entry.level === "warn") { + console.warn(serialized); + return; + } + console.log(serialized); + } +}; +var StructuredLogger = class _StructuredLogger { + constructor(options) { + this.options = options; + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + transport; + child(defaults) { + return new _StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults + }, + transport: this.transport + }); + } + debug(message, metadata = {}) { + this.log("debug", message, metadata); + } + info(message, metadata = {}) { + this.log("info", message, metadata); + } + warn(message, metadata = {}) { + this.log("warn", message, metadata); + } + error(message, metadata = {}) { + this.log("error", message, metadata); + } + log(level, message, metadata) { + if (!shouldLog(level)) { + return; + } + const context = getRequestContext(); + const entry = { + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? "development", + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata) + }; + Object.keys(entry).forEach((key) => { + if (entry[key] === void 0) { + delete entry[key]; + } + }); + this.transport.write(entry); + } +}; +function shouldLog(level) { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} +function parseLogLevel(value) { + if (value === "debug" || value === "info" || value === "warn" || value === "error") { + return value; + } + return "info"; +} +function serializeMetadata(metadata) { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === void 0) { + return result; + } + if (key === "error") { + result.error = serializeError(value); + return result; + } + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + result[key] = value; + return result; + }, {}); +} +function serializeError(error) { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack + }; + } + return error; +} +function createLogger(options = {}) { + return new StructuredLogger({ + serviceName: options.serviceName ?? "@stellar-pay/compliance-engine", + transport: options.transport, + defaults: options.defaults + }); +} +var logger = createLogger({ serviceName: "@stellar-pay/compliance-engine" }); +export { + StructuredLogger, + createCorrelationId, + createLogger, + getCorrelationId, + getRequestContext, + logger, + runWithCorrelationId, + runWithRequestContext +}; diff --git a/packages/compliance-engine/src/index.ts b/packages/compliance-engine/src/index.ts index e69de29..92c0130 100644 --- a/packages/compliance-engine/src/index.ts +++ b/packages/compliance-engine/src/index.ts @@ -0,0 +1,2 @@ +export * from './logger'; +export * from './request-context'; diff --git a/packages/compliance-engine/src/logger.ts b/packages/compliance-engine/src/logger.ts new file mode 100644 index 0000000..f3b1886 --- /dev/null +++ b/packages/compliance-engine/src/logger.ts @@ -0,0 +1,173 @@ +import { getCorrelationId, getRequestContext } from './request-context'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LOG_LEVEL_PRIORITY: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +export interface LogTransport { + write(entry: StructuredLogEntry): void; +} + +export interface StructuredLogEntry { + timestamp: string; + level: LogLevel; + service: string; + message: string; + environment: string; + correlationId?: string; + error?: { + name: string; + message: string; + stack?: string; + }; + [key: string]: unknown; +} + +export type LogMetadata = Record; + +interface LoggerOptions { + serviceName: string; + transport?: LogTransport; + defaults?: LogMetadata; +} + +class ConsoleJsonTransport implements LogTransport { + write(entry: StructuredLogEntry): void { + const serialized = JSON.stringify(entry); + + if (entry.level === 'error') { + console.error(serialized); + return; + } + + if (entry.level === 'warn') { + console.warn(serialized); + return; + } + + console.log(serialized); + } +} + +export class StructuredLogger { + private readonly transport: LogTransport; + + constructor(private readonly options: LoggerOptions) { + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + + child(defaults: LogMetadata): StructuredLogger { + return new StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults, + }, + transport: this.transport, + }); + } + + debug(message: string, metadata: LogMetadata = {}): void { + this.log('debug', message, metadata); + } + + info(message: string, metadata: LogMetadata = {}): void { + this.log('info', message, metadata); + } + + warn(message: string, metadata: LogMetadata = {}): void { + this.log('warn', message, metadata); + } + + error(message: string, metadata: LogMetadata = {}): void { + this.log('error', message, metadata); + } + + private log(level: LogLevel, message: string, metadata: LogMetadata): void { + if (!shouldLog(level)) { + return; + } + + const context = getRequestContext(); + const entry: StructuredLogEntry = { + timestamp: new Date().toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? 'development', + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata), + }; + + Object.keys(entry).forEach((key) => { + if (entry[key] === undefined) { + delete entry[key]; + } + }); + + this.transport.write(entry); + } +} + +function shouldLog(level: LogLevel): boolean { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} + +function parseLogLevel(value: string | undefined): LogLevel { + if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') { + return value; + } + + return 'info'; +} + +function serializeMetadata(metadata: LogMetadata): LogMetadata { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === undefined) { + return result; + } + + if (key === 'error') { + result.error = serializeError(value); + return result; + } + + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + + result[key] = value; + return result; + }, {}); +} + +function serializeError(error: unknown): StructuredLogEntry['error'] | unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + return error; +} + +export function createLogger(options: Partial = {}): StructuredLogger { + return new StructuredLogger({ + serviceName: options.serviceName ?? '@stellar-pay/compliance-engine', + transport: options.transport, + defaults: options.defaults, + }); +} + +export const logger = createLogger({ serviceName: '@stellar-pay/compliance-engine' }); diff --git a/packages/compliance-engine/src/request-context.ts b/packages/compliance-engine/src/request-context.ts new file mode 100644 index 0000000..9d7cd4a --- /dev/null +++ b/packages/compliance-engine/src/request-context.ts @@ -0,0 +1,42 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomUUID } from 'node:crypto'; + +export interface RequestContext { + correlationId: string; + requestId?: string; + traceId?: string; + userId?: string; + [key: string]: unknown; +} + +const storage = new AsyncLocalStorage(); + +export function createCorrelationId(seed?: string): string { + const value = seed?.trim(); + return value && value.length > 0 ? value : randomUUID(); +} + +export function runWithRequestContext(context: Partial, callback: () => T): T { + const current = storage.getStore(); + + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId), + }, + callback, + ); +} + +export function runWithCorrelationId(correlationId: string, callback: () => T): T { + return runWithRequestContext({ correlationId }, callback); +} + +export function getRequestContext(): RequestContext | undefined { + return storage.getStore(); +} + +export function getCorrelationId(): string | undefined { + return storage.getStore()?.correlationId; +} diff --git a/packages/escrow/dist/index.js b/packages/escrow/dist/index.js index 3918c74..ff2acb5 100644 --- a/packages/escrow/dist/index.js +++ b/packages/escrow/dist/index.js @@ -1 +1,191 @@ "use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/index.ts +var index_exports = {}; +__export(index_exports, { + StructuredLogger: () => StructuredLogger, + createCorrelationId: () => createCorrelationId, + createLogger: () => createLogger, + getCorrelationId: () => getCorrelationId, + getRequestContext: () => getRequestContext, + logger: () => logger, + runWithCorrelationId: () => runWithCorrelationId, + runWithRequestContext: () => runWithRequestContext +}); +module.exports = __toCommonJS(index_exports); + +// src/request-context.ts +var import_node_async_hooks = require("async_hooks"); +var import_node_crypto = require("crypto"); +var storage = new import_node_async_hooks.AsyncLocalStorage(); +function createCorrelationId(seed) { + const value = seed?.trim(); + return value && value.length > 0 ? value : (0, import_node_crypto.randomUUID)(); +} +function runWithRequestContext(context, callback) { + const current = storage.getStore(); + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId) + }, + callback + ); +} +function runWithCorrelationId(correlationId, callback) { + return runWithRequestContext({ correlationId }, callback); +} +function getRequestContext() { + return storage.getStore(); +} +function getCorrelationId() { + return storage.getStore()?.correlationId; +} + +// src/logger.ts +var LOG_LEVEL_PRIORITY = { + debug: 10, + info: 20, + warn: 30, + error: 40 +}; +var ConsoleJsonTransport = class { + write(entry) { + const serialized = JSON.stringify(entry); + if (entry.level === "error") { + console.error(serialized); + return; + } + if (entry.level === "warn") { + console.warn(serialized); + return; + } + console.log(serialized); + } +}; +var StructuredLogger = class _StructuredLogger { + constructor(options) { + this.options = options; + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + transport; + child(defaults) { + return new _StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults + }, + transport: this.transport + }); + } + debug(message, metadata = {}) { + this.log("debug", message, metadata); + } + info(message, metadata = {}) { + this.log("info", message, metadata); + } + warn(message, metadata = {}) { + this.log("warn", message, metadata); + } + error(message, metadata = {}) { + this.log("error", message, metadata); + } + log(level, message, metadata) { + if (!shouldLog(level)) { + return; + } + const context = getRequestContext(); + const entry = { + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? "development", + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata) + }; + Object.keys(entry).forEach((key) => { + if (entry[key] === void 0) { + delete entry[key]; + } + }); + this.transport.write(entry); + } +}; +function shouldLog(level) { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} +function parseLogLevel(value) { + if (value === "debug" || value === "info" || value === "warn" || value === "error") { + return value; + } + return "info"; +} +function serializeMetadata(metadata) { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === void 0) { + return result; + } + if (key === "error") { + result.error = serializeError(value); + return result; + } + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + result[key] = value; + return result; + }, {}); +} +function serializeError(error) { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack + }; + } + return error; +} +function createLogger(options = {}) { + return new StructuredLogger({ + serviceName: options.serviceName ?? "@stellar-pay/escrow", + transport: options.transport, + defaults: options.defaults + }); +} +var logger = createLogger({ serviceName: "@stellar-pay/escrow" }); +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + StructuredLogger, + createCorrelationId, + createLogger, + getCorrelationId, + getRequestContext, + logger, + runWithCorrelationId, + runWithRequestContext +}); diff --git a/packages/escrow/dist/index.mjs b/packages/escrow/dist/index.mjs index e69de29..1257d17 100644 --- a/packages/escrow/dist/index.mjs +++ b/packages/escrow/dist/index.mjs @@ -0,0 +1,157 @@ +// src/request-context.ts +import { AsyncLocalStorage } from "async_hooks"; +import { randomUUID } from "crypto"; +var storage = new AsyncLocalStorage(); +function createCorrelationId(seed) { + const value = seed?.trim(); + return value && value.length > 0 ? value : randomUUID(); +} +function runWithRequestContext(context, callback) { + const current = storage.getStore(); + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId) + }, + callback + ); +} +function runWithCorrelationId(correlationId, callback) { + return runWithRequestContext({ correlationId }, callback); +} +function getRequestContext() { + return storage.getStore(); +} +function getCorrelationId() { + return storage.getStore()?.correlationId; +} + +// src/logger.ts +var LOG_LEVEL_PRIORITY = { + debug: 10, + info: 20, + warn: 30, + error: 40 +}; +var ConsoleJsonTransport = class { + write(entry) { + const serialized = JSON.stringify(entry); + if (entry.level === "error") { + console.error(serialized); + return; + } + if (entry.level === "warn") { + console.warn(serialized); + return; + } + console.log(serialized); + } +}; +var StructuredLogger = class _StructuredLogger { + constructor(options) { + this.options = options; + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + transport; + child(defaults) { + return new _StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults + }, + transport: this.transport + }); + } + debug(message, metadata = {}) { + this.log("debug", message, metadata); + } + info(message, metadata = {}) { + this.log("info", message, metadata); + } + warn(message, metadata = {}) { + this.log("warn", message, metadata); + } + error(message, metadata = {}) { + this.log("error", message, metadata); + } + log(level, message, metadata) { + if (!shouldLog(level)) { + return; + } + const context = getRequestContext(); + const entry = { + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? "development", + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata) + }; + Object.keys(entry).forEach((key) => { + if (entry[key] === void 0) { + delete entry[key]; + } + }); + this.transport.write(entry); + } +}; +function shouldLog(level) { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} +function parseLogLevel(value) { + if (value === "debug" || value === "info" || value === "warn" || value === "error") { + return value; + } + return "info"; +} +function serializeMetadata(metadata) { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === void 0) { + return result; + } + if (key === "error") { + result.error = serializeError(value); + return result; + } + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + result[key] = value; + return result; + }, {}); +} +function serializeError(error) { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack + }; + } + return error; +} +function createLogger(options = {}) { + return new StructuredLogger({ + serviceName: options.serviceName ?? "@stellar-pay/escrow", + transport: options.transport, + defaults: options.defaults + }); +} +var logger = createLogger({ serviceName: "@stellar-pay/escrow" }); +export { + StructuredLogger, + createCorrelationId, + createLogger, + getCorrelationId, + getRequestContext, + logger, + runWithCorrelationId, + runWithRequestContext +}; diff --git a/packages/escrow/src/index.ts b/packages/escrow/src/index.ts index e69de29..92c0130 100644 --- a/packages/escrow/src/index.ts +++ b/packages/escrow/src/index.ts @@ -0,0 +1,2 @@ +export * from './logger'; +export * from './request-context'; diff --git a/packages/escrow/src/logger.ts b/packages/escrow/src/logger.ts new file mode 100644 index 0000000..2fff9ea --- /dev/null +++ b/packages/escrow/src/logger.ts @@ -0,0 +1,173 @@ +import { getCorrelationId, getRequestContext } from './request-context'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LOG_LEVEL_PRIORITY: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +export interface LogTransport { + write(entry: StructuredLogEntry): void; +} + +export interface StructuredLogEntry { + timestamp: string; + level: LogLevel; + service: string; + message: string; + environment: string; + correlationId?: string; + error?: { + name: string; + message: string; + stack?: string; + }; + [key: string]: unknown; +} + +export type LogMetadata = Record; + +interface LoggerOptions { + serviceName: string; + transport?: LogTransport; + defaults?: LogMetadata; +} + +class ConsoleJsonTransport implements LogTransport { + write(entry: StructuredLogEntry): void { + const serialized = JSON.stringify(entry); + + if (entry.level === 'error') { + console.error(serialized); + return; + } + + if (entry.level === 'warn') { + console.warn(serialized); + return; + } + + console.log(serialized); + } +} + +export class StructuredLogger { + private readonly transport: LogTransport; + + constructor(private readonly options: LoggerOptions) { + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + + child(defaults: LogMetadata): StructuredLogger { + return new StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults, + }, + transport: this.transport, + }); + } + + debug(message: string, metadata: LogMetadata = {}): void { + this.log('debug', message, metadata); + } + + info(message: string, metadata: LogMetadata = {}): void { + this.log('info', message, metadata); + } + + warn(message: string, metadata: LogMetadata = {}): void { + this.log('warn', message, metadata); + } + + error(message: string, metadata: LogMetadata = {}): void { + this.log('error', message, metadata); + } + + private log(level: LogLevel, message: string, metadata: LogMetadata): void { + if (!shouldLog(level)) { + return; + } + + const context = getRequestContext(); + const entry: StructuredLogEntry = { + timestamp: new Date().toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? 'development', + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata), + }; + + Object.keys(entry).forEach((key) => { + if (entry[key] === undefined) { + delete entry[key]; + } + }); + + this.transport.write(entry); + } +} + +function shouldLog(level: LogLevel): boolean { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} + +function parseLogLevel(value: string | undefined): LogLevel { + if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') { + return value; + } + + return 'info'; +} + +function serializeMetadata(metadata: LogMetadata): LogMetadata { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === undefined) { + return result; + } + + if (key === 'error') { + result.error = serializeError(value); + return result; + } + + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + + result[key] = value; + return result; + }, {}); +} + +function serializeError(error: unknown): StructuredLogEntry['error'] | unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + return error; +} + +export function createLogger(options: Partial = {}): StructuredLogger { + return new StructuredLogger({ + serviceName: options.serviceName ?? '@stellar-pay/escrow', + transport: options.transport, + defaults: options.defaults, + }); +} + +export const logger = createLogger({ serviceName: '@stellar-pay/escrow' }); diff --git a/packages/escrow/src/request-context.ts b/packages/escrow/src/request-context.ts new file mode 100644 index 0000000..9d7cd4a --- /dev/null +++ b/packages/escrow/src/request-context.ts @@ -0,0 +1,42 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomUUID } from 'node:crypto'; + +export interface RequestContext { + correlationId: string; + requestId?: string; + traceId?: string; + userId?: string; + [key: string]: unknown; +} + +const storage = new AsyncLocalStorage(); + +export function createCorrelationId(seed?: string): string { + const value = seed?.trim(); + return value && value.length > 0 ? value : randomUUID(); +} + +export function runWithRequestContext(context: Partial, callback: () => T): T { + const current = storage.getStore(); + + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId), + }, + callback, + ); +} + +export function runWithCorrelationId(correlationId: string, callback: () => T): T { + return runWithRequestContext({ correlationId }, callback); +} + +export function getRequestContext(): RequestContext | undefined { + return storage.getStore(); +} + +export function getCorrelationId(): string | undefined { + return storage.getStore()?.correlationId; +} diff --git a/packages/payments-engine/dist/index.js b/packages/payments-engine/dist/index.js index 4ffd2e4..82ee42d 100644 --- a/packages/payments-engine/dist/index.js +++ b/packages/payments-engine/dist/index.js @@ -30,10 +30,166 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru // src/index.ts var index_exports = {}; __export(index_exports, { - StellarService: () => StellarService + StellarService: () => StellarService, + StructuredLogger: () => StructuredLogger, + createCorrelationId: () => createCorrelationId, + createLogger: () => createLogger, + getCorrelationId: () => getCorrelationId, + getRequestContext: () => getRequestContext, + logger: () => logger, + runWithCorrelationId: () => runWithCorrelationId, + runWithRequestContext: () => runWithRequestContext }); module.exports = __toCommonJS(index_exports); +// src/request-context.ts +var import_node_async_hooks = require("async_hooks"); +var import_node_crypto = require("crypto"); +var storage = new import_node_async_hooks.AsyncLocalStorage(); +function createCorrelationId(seed) { + const value = seed?.trim(); + return value && value.length > 0 ? value : (0, import_node_crypto.randomUUID)(); +} +function runWithRequestContext(context, callback) { + const current = storage.getStore(); + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId) + }, + callback + ); +} +function runWithCorrelationId(correlationId, callback) { + return runWithRequestContext({ correlationId }, callback); +} +function getRequestContext() { + return storage.getStore(); +} +function getCorrelationId() { + return storage.getStore()?.correlationId; +} + +// src/logger.ts +var LOG_LEVEL_PRIORITY = { + debug: 10, + info: 20, + warn: 30, + error: 40 +}; +var ConsoleJsonTransport = class { + write(entry) { + const serialized = JSON.stringify(entry); + if (entry.level === "error") { + console.error(serialized); + return; + } + if (entry.level === "warn") { + console.warn(serialized); + return; + } + console.log(serialized); + } +}; +var StructuredLogger = class _StructuredLogger { + constructor(options) { + this.options = options; + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + transport; + child(defaults) { + return new _StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults + }, + transport: this.transport + }); + } + debug(message, metadata = {}) { + this.log("debug", message, metadata); + } + info(message, metadata = {}) { + this.log("info", message, metadata); + } + warn(message, metadata = {}) { + this.log("warn", message, metadata); + } + error(message, metadata = {}) { + this.log("error", message, metadata); + } + log(level, message, metadata) { + if (!shouldLog(level)) { + return; + } + const context = getRequestContext(); + const entry = { + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? "development", + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata) + }; + Object.keys(entry).forEach((key) => { + if (entry[key] === void 0) { + delete entry[key]; + } + }); + this.transport.write(entry); + } +}; +function shouldLog(level) { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} +function parseLogLevel(value) { + if (value === "debug" || value === "info" || value === "warn" || value === "error") { + return value; + } + return "info"; +} +function serializeMetadata(metadata) { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === void 0) { + return result; + } + if (key === "error") { + result.error = serializeError(value); + return result; + } + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + result[key] = value; + return result; + }, {}); +} +function serializeError(error) { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack + }; + } + return error; +} +function createLogger(options = {}) { + return new StructuredLogger({ + serviceName: options.serviceName ?? "@stellar-pay/payments-engine", + transport: options.transport, + defaults: options.defaults + }); +} +var logger = createLogger({ serviceName: "@stellar-pay/payments-engine" }); + // src/stellar.service.ts var StellarSdk = __toESM(require("stellar-sdk")); var StellarService = class { @@ -45,7 +201,7 @@ var StellarService = class { const secret = process.env.STELLAR_STORAGE_SECRET || "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; try { this.sourceKeypair = StellarSdk.Keypair.fromSecret(secret); - } catch (error) { + } catch { console.warn("Invalid STELLAR_STORAGE_SECRET. Stellar operations will fail."); } } @@ -58,11 +214,13 @@ var StellarService = class { const transaction = new StellarSdk.TransactionBuilder(sourceAccount, { fee: StellarSdk.BASE_FEE, networkPassphrase: process.env.STELLAR_NETWORK_URL?.includes("public") ? StellarSdk.Networks.PUBLIC : StellarSdk.Networks.TESTNET - }).addOperation(StellarSdk.Operation.payment({ - destination: destinationAddress, - asset: StellarSdk.Asset.native(), - amount - })).setTimeout(30).build(); + }).addOperation( + StellarSdk.Operation.payment({ + destination: destinationAddress, + asset: StellarSdk.Asset.native(), + amount + }) + ).setTimeout(30).build(); transaction.sign(this.sourceKeypair); const response = await this.server.submitTransaction(transaction); return response.hash; @@ -74,5 +232,13 @@ var StellarService = class { }; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { - StellarService + StellarService, + StructuredLogger, + createCorrelationId, + createLogger, + getCorrelationId, + getRequestContext, + logger, + runWithCorrelationId, + runWithRequestContext }); diff --git a/packages/payments-engine/dist/index.mjs b/packages/payments-engine/dist/index.mjs index 6b24069..63fc641 100644 --- a/packages/payments-engine/dist/index.mjs +++ b/packages/payments-engine/dist/index.mjs @@ -1,3 +1,151 @@ +// src/request-context.ts +import { AsyncLocalStorage } from "async_hooks"; +import { randomUUID } from "crypto"; +var storage = new AsyncLocalStorage(); +function createCorrelationId(seed) { + const value = seed?.trim(); + return value && value.length > 0 ? value : randomUUID(); +} +function runWithRequestContext(context, callback) { + const current = storage.getStore(); + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId) + }, + callback + ); +} +function runWithCorrelationId(correlationId, callback) { + return runWithRequestContext({ correlationId }, callback); +} +function getRequestContext() { + return storage.getStore(); +} +function getCorrelationId() { + return storage.getStore()?.correlationId; +} + +// src/logger.ts +var LOG_LEVEL_PRIORITY = { + debug: 10, + info: 20, + warn: 30, + error: 40 +}; +var ConsoleJsonTransport = class { + write(entry) { + const serialized = JSON.stringify(entry); + if (entry.level === "error") { + console.error(serialized); + return; + } + if (entry.level === "warn") { + console.warn(serialized); + return; + } + console.log(serialized); + } +}; +var StructuredLogger = class _StructuredLogger { + constructor(options) { + this.options = options; + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + transport; + child(defaults) { + return new _StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults + }, + transport: this.transport + }); + } + debug(message, metadata = {}) { + this.log("debug", message, metadata); + } + info(message, metadata = {}) { + this.log("info", message, metadata); + } + warn(message, metadata = {}) { + this.log("warn", message, metadata); + } + error(message, metadata = {}) { + this.log("error", message, metadata); + } + log(level, message, metadata) { + if (!shouldLog(level)) { + return; + } + const context = getRequestContext(); + const entry = { + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? "development", + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata) + }; + Object.keys(entry).forEach((key) => { + if (entry[key] === void 0) { + delete entry[key]; + } + }); + this.transport.write(entry); + } +}; +function shouldLog(level) { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} +function parseLogLevel(value) { + if (value === "debug" || value === "info" || value === "warn" || value === "error") { + return value; + } + return "info"; +} +function serializeMetadata(metadata) { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === void 0) { + return result; + } + if (key === "error") { + result.error = serializeError(value); + return result; + } + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + result[key] = value; + return result; + }, {}); +} +function serializeError(error) { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack + }; + } + return error; +} +function createLogger(options = {}) { + return new StructuredLogger({ + serviceName: options.serviceName ?? "@stellar-pay/payments-engine", + transport: options.transport, + defaults: options.defaults + }); +} +var logger = createLogger({ serviceName: "@stellar-pay/payments-engine" }); + // src/stellar.service.ts import * as StellarSdk from "stellar-sdk"; var StellarService = class { @@ -9,7 +157,7 @@ var StellarService = class { const secret = process.env.STELLAR_STORAGE_SECRET || "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; try { this.sourceKeypair = StellarSdk.Keypair.fromSecret(secret); - } catch (error) { + } catch { console.warn("Invalid STELLAR_STORAGE_SECRET. Stellar operations will fail."); } } @@ -22,11 +170,13 @@ var StellarService = class { const transaction = new StellarSdk.TransactionBuilder(sourceAccount, { fee: StellarSdk.BASE_FEE, networkPassphrase: process.env.STELLAR_NETWORK_URL?.includes("public") ? StellarSdk.Networks.PUBLIC : StellarSdk.Networks.TESTNET - }).addOperation(StellarSdk.Operation.payment({ - destination: destinationAddress, - asset: StellarSdk.Asset.native(), - amount - })).setTimeout(30).build(); + }).addOperation( + StellarSdk.Operation.payment({ + destination: destinationAddress, + asset: StellarSdk.Asset.native(), + amount + }) + ).setTimeout(30).build(); transaction.sign(this.sourceKeypair); const response = await this.server.submitTransaction(transaction); return response.hash; @@ -37,5 +187,13 @@ var StellarService = class { } }; export { - StellarService + StellarService, + StructuredLogger, + createCorrelationId, + createLogger, + getCorrelationId, + getRequestContext, + logger, + runWithCorrelationId, + runWithRequestContext }; diff --git a/packages/payments-engine/src/index.ts b/packages/payments-engine/src/index.ts index f56bb36..6f98564 100644 --- a/packages/payments-engine/src/index.ts +++ b/packages/payments-engine/src/index.ts @@ -1 +1,3 @@ +export * from './logger'; +export * from './request-context'; export * from './stellar.service'; diff --git a/packages/payments-engine/src/logger.ts b/packages/payments-engine/src/logger.ts new file mode 100644 index 0000000..6e42744 --- /dev/null +++ b/packages/payments-engine/src/logger.ts @@ -0,0 +1,173 @@ +import { getCorrelationId, getRequestContext } from './request-context'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LOG_LEVEL_PRIORITY: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +export interface LogTransport { + write(entry: StructuredLogEntry): void; +} + +export interface StructuredLogEntry { + timestamp: string; + level: LogLevel; + service: string; + message: string; + environment: string; + correlationId?: string; + error?: { + name: string; + message: string; + stack?: string; + }; + [key: string]: unknown; +} + +export type LogMetadata = Record; + +interface LoggerOptions { + serviceName: string; + transport?: LogTransport; + defaults?: LogMetadata; +} + +class ConsoleJsonTransport implements LogTransport { + write(entry: StructuredLogEntry): void { + const serialized = JSON.stringify(entry); + + if (entry.level === 'error') { + console.error(serialized); + return; + } + + if (entry.level === 'warn') { + console.warn(serialized); + return; + } + + console.log(serialized); + } +} + +export class StructuredLogger { + private readonly transport: LogTransport; + + constructor(private readonly options: LoggerOptions) { + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + + child(defaults: LogMetadata): StructuredLogger { + return new StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults, + }, + transport: this.transport, + }); + } + + debug(message: string, metadata: LogMetadata = {}): void { + this.log('debug', message, metadata); + } + + info(message: string, metadata: LogMetadata = {}): void { + this.log('info', message, metadata); + } + + warn(message: string, metadata: LogMetadata = {}): void { + this.log('warn', message, metadata); + } + + error(message: string, metadata: LogMetadata = {}): void { + this.log('error', message, metadata); + } + + private log(level: LogLevel, message: string, metadata: LogMetadata): void { + if (!shouldLog(level)) { + return; + } + + const context = getRequestContext(); + const entry: StructuredLogEntry = { + timestamp: new Date().toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? 'development', + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata), + }; + + Object.keys(entry).forEach((key) => { + if (entry[key] === undefined) { + delete entry[key]; + } + }); + + this.transport.write(entry); + } +} + +function shouldLog(level: LogLevel): boolean { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} + +function parseLogLevel(value: string | undefined): LogLevel { + if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') { + return value; + } + + return 'info'; +} + +function serializeMetadata(metadata: LogMetadata): LogMetadata { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === undefined) { + return result; + } + + if (key === 'error') { + result.error = serializeError(value); + return result; + } + + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + + result[key] = value; + return result; + }, {}); +} + +function serializeError(error: unknown): StructuredLogEntry['error'] | unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + return error; +} + +export function createLogger(options: Partial = {}): StructuredLogger { + return new StructuredLogger({ + serviceName: options.serviceName ?? '@stellar-pay/payments-engine', + transport: options.transport, + defaults: options.defaults, + }); +} + +export const logger = createLogger({ serviceName: '@stellar-pay/payments-engine' }); diff --git a/packages/payments-engine/src/request-context.ts b/packages/payments-engine/src/request-context.ts new file mode 100644 index 0000000..9d7cd4a --- /dev/null +++ b/packages/payments-engine/src/request-context.ts @@ -0,0 +1,42 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomUUID } from 'node:crypto'; + +export interface RequestContext { + correlationId: string; + requestId?: string; + traceId?: string; + userId?: string; + [key: string]: unknown; +} + +const storage = new AsyncLocalStorage(); + +export function createCorrelationId(seed?: string): string { + const value = seed?.trim(); + return value && value.length > 0 ? value : randomUUID(); +} + +export function runWithRequestContext(context: Partial, callback: () => T): T { + const current = storage.getStore(); + + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId), + }, + callback, + ); +} + +export function runWithCorrelationId(correlationId: string, callback: () => T): T { + return runWithRequestContext({ correlationId }, callback); +} + +export function getRequestContext(): RequestContext | undefined { + return storage.getStore(); +} + +export function getCorrelationId(): string | undefined { + return storage.getStore()?.correlationId; +} diff --git a/packages/sdk-js/dist/index.js b/packages/sdk-js/dist/index.js index 3918c74..18286a6 100644 --- a/packages/sdk-js/dist/index.js +++ b/packages/sdk-js/dist/index.js @@ -1 +1,191 @@ "use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/index.ts +var index_exports = {}; +__export(index_exports, { + StructuredLogger: () => StructuredLogger, + createCorrelationId: () => createCorrelationId, + createLogger: () => createLogger, + getCorrelationId: () => getCorrelationId, + getRequestContext: () => getRequestContext, + logger: () => logger, + runWithCorrelationId: () => runWithCorrelationId, + runWithRequestContext: () => runWithRequestContext +}); +module.exports = __toCommonJS(index_exports); + +// src/request-context.ts +var import_node_async_hooks = require("async_hooks"); +var import_node_crypto = require("crypto"); +var storage = new import_node_async_hooks.AsyncLocalStorage(); +function createCorrelationId(seed) { + const value = seed?.trim(); + return value && value.length > 0 ? value : (0, import_node_crypto.randomUUID)(); +} +function runWithRequestContext(context, callback) { + const current = storage.getStore(); + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId) + }, + callback + ); +} +function runWithCorrelationId(correlationId, callback) { + return runWithRequestContext({ correlationId }, callback); +} +function getRequestContext() { + return storage.getStore(); +} +function getCorrelationId() { + return storage.getStore()?.correlationId; +} + +// src/logger.ts +var LOG_LEVEL_PRIORITY = { + debug: 10, + info: 20, + warn: 30, + error: 40 +}; +var ConsoleJsonTransport = class { + write(entry) { + const serialized = JSON.stringify(entry); + if (entry.level === "error") { + console.error(serialized); + return; + } + if (entry.level === "warn") { + console.warn(serialized); + return; + } + console.log(serialized); + } +}; +var StructuredLogger = class _StructuredLogger { + constructor(options) { + this.options = options; + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + transport; + child(defaults) { + return new _StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults + }, + transport: this.transport + }); + } + debug(message, metadata = {}) { + this.log("debug", message, metadata); + } + info(message, metadata = {}) { + this.log("info", message, metadata); + } + warn(message, metadata = {}) { + this.log("warn", message, metadata); + } + error(message, metadata = {}) { + this.log("error", message, metadata); + } + log(level, message, metadata) { + if (!shouldLog(level)) { + return; + } + const context = getRequestContext(); + const entry = { + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? "development", + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata) + }; + Object.keys(entry).forEach((key) => { + if (entry[key] === void 0) { + delete entry[key]; + } + }); + this.transport.write(entry); + } +}; +function shouldLog(level) { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} +function parseLogLevel(value) { + if (value === "debug" || value === "info" || value === "warn" || value === "error") { + return value; + } + return "info"; +} +function serializeMetadata(metadata) { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === void 0) { + return result; + } + if (key === "error") { + result.error = serializeError(value); + return result; + } + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + result[key] = value; + return result; + }, {}); +} +function serializeError(error) { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack + }; + } + return error; +} +function createLogger(options = {}) { + return new StructuredLogger({ + serviceName: options.serviceName ?? "@stellar-pay/sdk-js", + transport: options.transport, + defaults: options.defaults + }); +} +var logger = createLogger({ serviceName: "@stellar-pay/sdk-js" }); +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + StructuredLogger, + createCorrelationId, + createLogger, + getCorrelationId, + getRequestContext, + logger, + runWithCorrelationId, + runWithRequestContext +}); diff --git a/packages/sdk-js/dist/index.mjs b/packages/sdk-js/dist/index.mjs index e69de29..8c96762 100644 --- a/packages/sdk-js/dist/index.mjs +++ b/packages/sdk-js/dist/index.mjs @@ -0,0 +1,157 @@ +// src/request-context.ts +import { AsyncLocalStorage } from "async_hooks"; +import { randomUUID } from "crypto"; +var storage = new AsyncLocalStorage(); +function createCorrelationId(seed) { + const value = seed?.trim(); + return value && value.length > 0 ? value : randomUUID(); +} +function runWithRequestContext(context, callback) { + const current = storage.getStore(); + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId) + }, + callback + ); +} +function runWithCorrelationId(correlationId, callback) { + return runWithRequestContext({ correlationId }, callback); +} +function getRequestContext() { + return storage.getStore(); +} +function getCorrelationId() { + return storage.getStore()?.correlationId; +} + +// src/logger.ts +var LOG_LEVEL_PRIORITY = { + debug: 10, + info: 20, + warn: 30, + error: 40 +}; +var ConsoleJsonTransport = class { + write(entry) { + const serialized = JSON.stringify(entry); + if (entry.level === "error") { + console.error(serialized); + return; + } + if (entry.level === "warn") { + console.warn(serialized); + return; + } + console.log(serialized); + } +}; +var StructuredLogger = class _StructuredLogger { + constructor(options) { + this.options = options; + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + transport; + child(defaults) { + return new _StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults + }, + transport: this.transport + }); + } + debug(message, metadata = {}) { + this.log("debug", message, metadata); + } + info(message, metadata = {}) { + this.log("info", message, metadata); + } + warn(message, metadata = {}) { + this.log("warn", message, metadata); + } + error(message, metadata = {}) { + this.log("error", message, metadata); + } + log(level, message, metadata) { + if (!shouldLog(level)) { + return; + } + const context = getRequestContext(); + const entry = { + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? "development", + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata) + }; + Object.keys(entry).forEach((key) => { + if (entry[key] === void 0) { + delete entry[key]; + } + }); + this.transport.write(entry); + } +}; +function shouldLog(level) { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} +function parseLogLevel(value) { + if (value === "debug" || value === "info" || value === "warn" || value === "error") { + return value; + } + return "info"; +} +function serializeMetadata(metadata) { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === void 0) { + return result; + } + if (key === "error") { + result.error = serializeError(value); + return result; + } + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + result[key] = value; + return result; + }, {}); +} +function serializeError(error) { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack + }; + } + return error; +} +function createLogger(options = {}) { + return new StructuredLogger({ + serviceName: options.serviceName ?? "@stellar-pay/sdk-js", + transport: options.transport, + defaults: options.defaults + }); +} +var logger = createLogger({ serviceName: "@stellar-pay/sdk-js" }); +export { + StructuredLogger, + createCorrelationId, + createLogger, + getCorrelationId, + getRequestContext, + logger, + runWithCorrelationId, + runWithRequestContext +}; diff --git a/packages/sdk-js/src/index.ts b/packages/sdk-js/src/index.ts index e69de29..92c0130 100644 --- a/packages/sdk-js/src/index.ts +++ b/packages/sdk-js/src/index.ts @@ -0,0 +1,2 @@ +export * from './logger'; +export * from './request-context'; diff --git a/packages/sdk-js/src/logger.ts b/packages/sdk-js/src/logger.ts new file mode 100644 index 0000000..06900a3 --- /dev/null +++ b/packages/sdk-js/src/logger.ts @@ -0,0 +1,173 @@ +import { getCorrelationId, getRequestContext } from './request-context'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LOG_LEVEL_PRIORITY: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +export interface LogTransport { + write(entry: StructuredLogEntry): void; +} + +export interface StructuredLogEntry { + timestamp: string; + level: LogLevel; + service: string; + message: string; + environment: string; + correlationId?: string; + error?: { + name: string; + message: string; + stack?: string; + }; + [key: string]: unknown; +} + +export type LogMetadata = Record; + +interface LoggerOptions { + serviceName: string; + transport?: LogTransport; + defaults?: LogMetadata; +} + +class ConsoleJsonTransport implements LogTransport { + write(entry: StructuredLogEntry): void { + const serialized = JSON.stringify(entry); + + if (entry.level === 'error') { + console.error(serialized); + return; + } + + if (entry.level === 'warn') { + console.warn(serialized); + return; + } + + console.log(serialized); + } +} + +export class StructuredLogger { + private readonly transport: LogTransport; + + constructor(private readonly options: LoggerOptions) { + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + + child(defaults: LogMetadata): StructuredLogger { + return new StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults, + }, + transport: this.transport, + }); + } + + debug(message: string, metadata: LogMetadata = {}): void { + this.log('debug', message, metadata); + } + + info(message: string, metadata: LogMetadata = {}): void { + this.log('info', message, metadata); + } + + warn(message: string, metadata: LogMetadata = {}): void { + this.log('warn', message, metadata); + } + + error(message: string, metadata: LogMetadata = {}): void { + this.log('error', message, metadata); + } + + private log(level: LogLevel, message: string, metadata: LogMetadata): void { + if (!shouldLog(level)) { + return; + } + + const context = getRequestContext(); + const entry: StructuredLogEntry = { + timestamp: new Date().toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? 'development', + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata), + }; + + Object.keys(entry).forEach((key) => { + if (entry[key] === undefined) { + delete entry[key]; + } + }); + + this.transport.write(entry); + } +} + +function shouldLog(level: LogLevel): boolean { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} + +function parseLogLevel(value: string | undefined): LogLevel { + if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') { + return value; + } + + return 'info'; +} + +function serializeMetadata(metadata: LogMetadata): LogMetadata { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === undefined) { + return result; + } + + if (key === 'error') { + result.error = serializeError(value); + return result; + } + + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + + result[key] = value; + return result; + }, {}); +} + +function serializeError(error: unknown): StructuredLogEntry['error'] | unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + return error; +} + +export function createLogger(options: Partial = {}): StructuredLogger { + return new StructuredLogger({ + serviceName: options.serviceName ?? '@stellar-pay/sdk-js', + transport: options.transport, + defaults: options.defaults, + }); +} + +export const logger = createLogger({ serviceName: '@stellar-pay/sdk-js' }); diff --git a/packages/sdk-js/src/request-context.ts b/packages/sdk-js/src/request-context.ts new file mode 100644 index 0000000..9d7cd4a --- /dev/null +++ b/packages/sdk-js/src/request-context.ts @@ -0,0 +1,42 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomUUID } from 'node:crypto'; + +export interface RequestContext { + correlationId: string; + requestId?: string; + traceId?: string; + userId?: string; + [key: string]: unknown; +} + +const storage = new AsyncLocalStorage(); + +export function createCorrelationId(seed?: string): string { + const value = seed?.trim(); + return value && value.length > 0 ? value : randomUUID(); +} + +export function runWithRequestContext(context: Partial, callback: () => T): T { + const current = storage.getStore(); + + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId), + }, + callback, + ); +} + +export function runWithCorrelationId(correlationId: string, callback: () => T): T { + return runWithRequestContext({ correlationId }, callback); +} + +export function getRequestContext(): RequestContext | undefined { + return storage.getStore(); +} + +export function getCorrelationId(): string | undefined { + return storage.getStore()?.correlationId; +} diff --git a/packages/subscriptions/dist/index.js b/packages/subscriptions/dist/index.js index 3918c74..85872af 100644 --- a/packages/subscriptions/dist/index.js +++ b/packages/subscriptions/dist/index.js @@ -1 +1,191 @@ "use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/index.ts +var index_exports = {}; +__export(index_exports, { + StructuredLogger: () => StructuredLogger, + createCorrelationId: () => createCorrelationId, + createLogger: () => createLogger, + getCorrelationId: () => getCorrelationId, + getRequestContext: () => getRequestContext, + logger: () => logger, + runWithCorrelationId: () => runWithCorrelationId, + runWithRequestContext: () => runWithRequestContext +}); +module.exports = __toCommonJS(index_exports); + +// src/request-context.ts +var import_node_async_hooks = require("async_hooks"); +var import_node_crypto = require("crypto"); +var storage = new import_node_async_hooks.AsyncLocalStorage(); +function createCorrelationId(seed) { + const value = seed?.trim(); + return value && value.length > 0 ? value : (0, import_node_crypto.randomUUID)(); +} +function runWithRequestContext(context, callback) { + const current = storage.getStore(); + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId) + }, + callback + ); +} +function runWithCorrelationId(correlationId, callback) { + return runWithRequestContext({ correlationId }, callback); +} +function getRequestContext() { + return storage.getStore(); +} +function getCorrelationId() { + return storage.getStore()?.correlationId; +} + +// src/logger.ts +var LOG_LEVEL_PRIORITY = { + debug: 10, + info: 20, + warn: 30, + error: 40 +}; +var ConsoleJsonTransport = class { + write(entry) { + const serialized = JSON.stringify(entry); + if (entry.level === "error") { + console.error(serialized); + return; + } + if (entry.level === "warn") { + console.warn(serialized); + return; + } + console.log(serialized); + } +}; +var StructuredLogger = class _StructuredLogger { + constructor(options) { + this.options = options; + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + transport; + child(defaults) { + return new _StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults + }, + transport: this.transport + }); + } + debug(message, metadata = {}) { + this.log("debug", message, metadata); + } + info(message, metadata = {}) { + this.log("info", message, metadata); + } + warn(message, metadata = {}) { + this.log("warn", message, metadata); + } + error(message, metadata = {}) { + this.log("error", message, metadata); + } + log(level, message, metadata) { + if (!shouldLog(level)) { + return; + } + const context = getRequestContext(); + const entry = { + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? "development", + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata) + }; + Object.keys(entry).forEach((key) => { + if (entry[key] === void 0) { + delete entry[key]; + } + }); + this.transport.write(entry); + } +}; +function shouldLog(level) { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} +function parseLogLevel(value) { + if (value === "debug" || value === "info" || value === "warn" || value === "error") { + return value; + } + return "info"; +} +function serializeMetadata(metadata) { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === void 0) { + return result; + } + if (key === "error") { + result.error = serializeError(value); + return result; + } + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + result[key] = value; + return result; + }, {}); +} +function serializeError(error) { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack + }; + } + return error; +} +function createLogger(options = {}) { + return new StructuredLogger({ + serviceName: options.serviceName ?? "@stellar-pay/subscriptions", + transport: options.transport, + defaults: options.defaults + }); +} +var logger = createLogger({ serviceName: "@stellar-pay/subscriptions" }); +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + StructuredLogger, + createCorrelationId, + createLogger, + getCorrelationId, + getRequestContext, + logger, + runWithCorrelationId, + runWithRequestContext +}); diff --git a/packages/subscriptions/dist/index.mjs b/packages/subscriptions/dist/index.mjs index e69de29..57a0f25 100644 --- a/packages/subscriptions/dist/index.mjs +++ b/packages/subscriptions/dist/index.mjs @@ -0,0 +1,157 @@ +// src/request-context.ts +import { AsyncLocalStorage } from "async_hooks"; +import { randomUUID } from "crypto"; +var storage = new AsyncLocalStorage(); +function createCorrelationId(seed) { + const value = seed?.trim(); + return value && value.length > 0 ? value : randomUUID(); +} +function runWithRequestContext(context, callback) { + const current = storage.getStore(); + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId) + }, + callback + ); +} +function runWithCorrelationId(correlationId, callback) { + return runWithRequestContext({ correlationId }, callback); +} +function getRequestContext() { + return storage.getStore(); +} +function getCorrelationId() { + return storage.getStore()?.correlationId; +} + +// src/logger.ts +var LOG_LEVEL_PRIORITY = { + debug: 10, + info: 20, + warn: 30, + error: 40 +}; +var ConsoleJsonTransport = class { + write(entry) { + const serialized = JSON.stringify(entry); + if (entry.level === "error") { + console.error(serialized); + return; + } + if (entry.level === "warn") { + console.warn(serialized); + return; + } + console.log(serialized); + } +}; +var StructuredLogger = class _StructuredLogger { + constructor(options) { + this.options = options; + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + transport; + child(defaults) { + return new _StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults + }, + transport: this.transport + }); + } + debug(message, metadata = {}) { + this.log("debug", message, metadata); + } + info(message, metadata = {}) { + this.log("info", message, metadata); + } + warn(message, metadata = {}) { + this.log("warn", message, metadata); + } + error(message, metadata = {}) { + this.log("error", message, metadata); + } + log(level, message, metadata) { + if (!shouldLog(level)) { + return; + } + const context = getRequestContext(); + const entry = { + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? "development", + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata) + }; + Object.keys(entry).forEach((key) => { + if (entry[key] === void 0) { + delete entry[key]; + } + }); + this.transport.write(entry); + } +}; +function shouldLog(level) { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} +function parseLogLevel(value) { + if (value === "debug" || value === "info" || value === "warn" || value === "error") { + return value; + } + return "info"; +} +function serializeMetadata(metadata) { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === void 0) { + return result; + } + if (key === "error") { + result.error = serializeError(value); + return result; + } + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + result[key] = value; + return result; + }, {}); +} +function serializeError(error) { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack + }; + } + return error; +} +function createLogger(options = {}) { + return new StructuredLogger({ + serviceName: options.serviceName ?? "@stellar-pay/subscriptions", + transport: options.transport, + defaults: options.defaults + }); +} +var logger = createLogger({ serviceName: "@stellar-pay/subscriptions" }); +export { + StructuredLogger, + createCorrelationId, + createLogger, + getCorrelationId, + getRequestContext, + logger, + runWithCorrelationId, + runWithRequestContext +}; diff --git a/packages/subscriptions/src/index.ts b/packages/subscriptions/src/index.ts index e69de29..92c0130 100644 --- a/packages/subscriptions/src/index.ts +++ b/packages/subscriptions/src/index.ts @@ -0,0 +1,2 @@ +export * from './logger'; +export * from './request-context'; diff --git a/packages/subscriptions/src/logger.ts b/packages/subscriptions/src/logger.ts new file mode 100644 index 0000000..7aa61d2 --- /dev/null +++ b/packages/subscriptions/src/logger.ts @@ -0,0 +1,173 @@ +import { getCorrelationId, getRequestContext } from './request-context'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LOG_LEVEL_PRIORITY: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +export interface LogTransport { + write(entry: StructuredLogEntry): void; +} + +export interface StructuredLogEntry { + timestamp: string; + level: LogLevel; + service: string; + message: string; + environment: string; + correlationId?: string; + error?: { + name: string; + message: string; + stack?: string; + }; + [key: string]: unknown; +} + +export type LogMetadata = Record; + +interface LoggerOptions { + serviceName: string; + transport?: LogTransport; + defaults?: LogMetadata; +} + +class ConsoleJsonTransport implements LogTransport { + write(entry: StructuredLogEntry): void { + const serialized = JSON.stringify(entry); + + if (entry.level === 'error') { + console.error(serialized); + return; + } + + if (entry.level === 'warn') { + console.warn(serialized); + return; + } + + console.log(serialized); + } +} + +export class StructuredLogger { + private readonly transport: LogTransport; + + constructor(private readonly options: LoggerOptions) { + this.transport = options.transport ?? new ConsoleJsonTransport(); + } + + child(defaults: LogMetadata): StructuredLogger { + return new StructuredLogger({ + ...this.options, + defaults: { + ...this.options.defaults, + ...defaults, + }, + transport: this.transport, + }); + } + + debug(message: string, metadata: LogMetadata = {}): void { + this.log('debug', message, metadata); + } + + info(message: string, metadata: LogMetadata = {}): void { + this.log('info', message, metadata); + } + + warn(message: string, metadata: LogMetadata = {}): void { + this.log('warn', message, metadata); + } + + error(message: string, metadata: LogMetadata = {}): void { + this.log('error', message, metadata); + } + + private log(level: LogLevel, message: string, metadata: LogMetadata): void { + if (!shouldLog(level)) { + return; + } + + const context = getRequestContext(); + const entry: StructuredLogEntry = { + timestamp: new Date().toISOString(), + level, + service: this.options.serviceName, + message, + environment: process.env.NODE_ENV ?? 'development', + correlationId: getCorrelationId(), + ...this.options.defaults, + ...context, + ...serializeMetadata(metadata), + }; + + Object.keys(entry).forEach((key) => { + if (entry[key] === undefined) { + delete entry[key]; + } + }); + + this.transport.write(entry); + } +} + +function shouldLog(level: LogLevel): boolean { + const configuredLevel = parseLogLevel(process.env.LOG_LEVEL); + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[configuredLevel]; +} + +function parseLogLevel(value: string | undefined): LogLevel { + if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') { + return value; + } + + return 'info'; +} + +function serializeMetadata(metadata: LogMetadata): LogMetadata { + return Object.entries(metadata).reduce((result, [key, value]) => { + if (value === undefined) { + return result; + } + + if (key === 'error') { + result.error = serializeError(value); + return result; + } + + if (value instanceof Error) { + result[key] = serializeError(value); + return result; + } + + result[key] = value; + return result; + }, {}); +} + +function serializeError(error: unknown): StructuredLogEntry['error'] | unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + return error; +} + +export function createLogger(options: Partial = {}): StructuredLogger { + return new StructuredLogger({ + serviceName: options.serviceName ?? '@stellar-pay/subscriptions', + transport: options.transport, + defaults: options.defaults, + }); +} + +export const logger = createLogger({ serviceName: '@stellar-pay/subscriptions' }); diff --git a/packages/subscriptions/src/request-context.ts b/packages/subscriptions/src/request-context.ts new file mode 100644 index 0000000..9d7cd4a --- /dev/null +++ b/packages/subscriptions/src/request-context.ts @@ -0,0 +1,42 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomUUID } from 'node:crypto'; + +export interface RequestContext { + correlationId: string; + requestId?: string; + traceId?: string; + userId?: string; + [key: string]: unknown; +} + +const storage = new AsyncLocalStorage(); + +export function createCorrelationId(seed?: string): string { + const value = seed?.trim(); + return value && value.length > 0 ? value : randomUUID(); +} + +export function runWithRequestContext(context: Partial, callback: () => T): T { + const current = storage.getStore(); + + return storage.run( + { + ...current, + ...context, + correlationId: createCorrelationId(context.correlationId ?? current?.correlationId), + }, + callback, + ); +} + +export function runWithCorrelationId(correlationId: string, callback: () => T): T { + return runWithRequestContext({ correlationId }, callback); +} + +export function getRequestContext(): RequestContext | undefined { + return storage.getStore(); +} + +export function getCorrelationId(): string | undefined { + return storage.getStore()?.correlationId; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4159bdb..a4870e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,9 @@ importers: '@eslint/js': specifier: ^9.39.3 version: 9.39.3 + '@types/node': + specifier: 22.10.7 + version: 22.10.7 '@typescript-eslint/eslint-plugin': specifier: ^8.56.1 version: 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) @@ -4902,6 +4905,12 @@ packages: integrity: sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==, } + '@types/node@22.10.7': + resolution: + { + integrity: sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==, + } + '@types/node@22.19.13': resolution: { @@ -11515,6 +11524,12 @@ packages: } engines: { node: '>= 0.4' } + undici-types@6.20.0: + resolution: + { + integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==, + } + undici-types@6.21.0: resolution: { @@ -15128,6 +15143,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.10.7': + dependencies: + undici-types: 6.20.0 + '@types/node@22.19.13': dependencies: undici-types: 6.21.0 @@ -19565,6 +19584,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@6.20.0: {} + undici-types@6.21.0: {} unicorn-magic@0.3.0: {} diff --git a/scripts/observability/deploy-cloudwatch.ps1 b/scripts/observability/deploy-cloudwatch.ps1 new file mode 100644 index 0000000..0b5e5c3 --- /dev/null +++ b/scripts/observability/deploy-cloudwatch.ps1 @@ -0,0 +1,77 @@ +param( + [string]$Region = 'us-east-1', + [string]$LogGroup = '/stellar-pay/production', + [string]$DashboardName = 'stellar-pay-observability', + [string]$AlarmTopicArn = '', + [int]$RetentionDays = 30 +) + +$ErrorActionPreference = 'Stop' + +function Replace-Placeholders { + param( + [Parameter(Mandatory = $true)] + [string]$Content + ) + + return $Content.Replace('__REGION__', $Region).Replace('__LOG_GROUP__', $LogGroup).Replace('__DASHBOARD_NAME__', $DashboardName) +} + +Write-Host "Ensuring CloudWatch log group $LogGroup exists in $Region" +$existingGroup = aws logs describe-log-groups --region $Region --log-group-name-prefix $LogGroup | ConvertFrom-Json +if (-not ($existingGroup.logGroups | Where-Object { $_.logGroupName -eq $LogGroup })) { + aws logs create-log-group --region $Region --log-group-name $LogGroup | Out-Null +} + +aws logs put-retention-policy --region $Region --log-group-name $LogGroup --retention-in-days $RetentionDays | Out-Null + +$metricFiltersPath = Join-Path $PSScriptRoot '..\..\docs\observability\cloudwatch-metric-filters.json' +$metricFilters = Get-Content $metricFiltersPath -Raw | Replace-Placeholders | ConvertFrom-Json +foreach ($filter in $metricFilters) { + $transformations = $filter.metricTransformations | ConvertTo-Json -Compress -Depth 10 + aws logs put-metric-filter ` + --region $Region ` + --log-group-name $LogGroup ` + --filter-name $filter.filterName ` + --filter-pattern $filter.filterPattern ` + --metric-transformations $transformations | Out-Null +} + +$dashboardPath = Join-Path $PSScriptRoot '..\..\docs\observability\cloudwatch-dashboard.json' +$dashboardBody = Get-Content $dashboardPath -Raw | Replace-Placeholders +aws cloudwatch put-dashboard --region $Region --dashboard-name $DashboardName --dashboard-body $dashboardBody | Out-Null + +$alarmsPath = Join-Path $PSScriptRoot '..\..\docs\observability\cloudwatch-alarms.json' +$alarms = Get-Content $alarmsPath -Raw | Replace-Placeholders | ConvertFrom-Json +foreach ($alarm in $alarms) { + $dimensionsArgs = @() + foreach ($dimension in $alarm.Dimensions) { + $dimensionsArgs += "Name=$($dimension.Name),Value=$($dimension.Value)" + } + + $command = @( + 'cloudwatch', + 'put-metric-alarm', + '--region', $Region, + '--alarm-name', $alarm.AlarmName, + '--alarm-description', $alarm.AlarmDescription, + '--namespace', $alarm.Namespace, + '--metric-name', $alarm.MetricName, + '--statistic', $alarm.Statistic, + '--period', $alarm.Period, + '--evaluation-periods', $alarm.EvaluationPeriods, + '--datapoints-to-alarm', $alarm.DatapointsToAlarm, + '--threshold', $alarm.Threshold, + '--comparison-operator', $alarm.ComparisonOperator, + '--treat-missing-data', $alarm.TreatMissingData, + '--dimensions' + ) + $dimensionsArgs + + if ($AlarmTopicArn) { + $command += @('--alarm-actions', $AlarmTopicArn) + } + + aws @command | Out-Null +} + +Write-Host 'CloudWatch observability assets applied successfully.'
TimestampActionActorIP AddressDetailsStatus
TimestampServiceLevelCorrelation IDRouteMessageLatency
{log.timestamp}{log.action}{log.actor}{log.ip}{log.details} + {log.timestamp}{log.service} - - {log.status} + {log.level} {log.correlationId}{log.route}{log.message}{log.latency}