From e97a6b3acdf9b2085cb35a2a969a1e464535441a Mon Sep 17 00:00:00 2001 From: Obiajulu-gif Date: Tue, 24 Mar 2026 21:46:13 +0100 Subject: [PATCH] Add CloudWatch log aggregation and correlation IDs --- README.md | 4 + apps/api/.env.example | 5 + apps/api/src/app.controller.ts | 9 + apps/api/src/app.module.ts | 9 +- apps/api/src/app.service.ts | 9 + apps/api/src/health/health.controller.ts | 9 + apps/api/src/main.ts | 17 +- apps/api/src/observability/index.ts | 5 + apps/api/src/observability/logger.spec.ts | 52 +++ apps/api/src/observability/logger.ts | 151 ++++++++ .../observability/logging-exception.filter.ts | 36 ++ .../request-context.middleware.ts | 39 ++ apps/api/src/observability/request-context.ts | 42 +++ .../request-logging.interceptor.ts | 45 +++ apps/api/src/treasury/treasury.controller.ts | 10 + apps/api/src/treasury/treasury.service.ts | 31 +- .../src/app/dashboard/audit-logs/page.tsx | 338 +++++++++++++----- docs/observability/README.md | 65 ++++ docs/observability/cloudwatch-alarms.json | 40 +++ docs/observability/cloudwatch-dashboard.json | 78 ++++ .../cloudwatch-metric-filters.json | 47 +++ packages/anchor-service/src/index.ts | 2 + packages/anchor-service/src/logger.ts | 173 +++++++++ .../anchor-service/src/request-context.ts | 42 +++ packages/compliance-engine/src/index.ts | 2 + packages/compliance-engine/src/logger.ts | 173 +++++++++ .../compliance-engine/src/request-context.ts | 42 +++ packages/escrow/src/index.ts | 2 + packages/escrow/src/logger.ts | 173 +++++++++ packages/escrow/src/request-context.ts | 42 +++ packages/payments-engine/src/index.ts | 2 + packages/payments-engine/src/logger.ts | 173 +++++++++ .../payments-engine/src/request-context.ts | 42 +++ packages/sdk-js/src/index.ts | 2 + packages/sdk-js/src/logger.ts | 173 +++++++++ packages/sdk-js/src/request-context.ts | 42 +++ packages/subscriptions/src/index.ts | 2 + packages/subscriptions/src/logger.ts | 173 +++++++++ packages/subscriptions/src/request-context.ts | 42 +++ scripts/observability/deploy-cloudwatch.ps1 | 77 ++++ 40 files changed, 2334 insertions(+), 86 deletions(-) create mode 100644 apps/api/src/observability/index.ts create mode 100644 apps/api/src/observability/logger.spec.ts create mode 100644 apps/api/src/observability/logger.ts create mode 100644 apps/api/src/observability/logging-exception.filter.ts create mode 100644 apps/api/src/observability/request-context.middleware.ts create mode 100644 apps/api/src/observability/request-context.ts create mode 100644 apps/api/src/observability/request-logging.interceptor.ts create mode 100644 docs/observability/README.md create mode 100644 docs/observability/cloudwatch-alarms.json create mode 100644 docs/observability/cloudwatch-dashboard.json create mode 100644 docs/observability/cloudwatch-metric-filters.json create mode 100644 packages/anchor-service/src/logger.ts create mode 100644 packages/anchor-service/src/request-context.ts create mode 100644 packages/compliance-engine/src/logger.ts create mode 100644 packages/compliance-engine/src/request-context.ts create mode 100644 packages/escrow/src/logger.ts create mode 100644 packages/escrow/src/request-context.ts create mode 100644 packages/payments-engine/src/logger.ts create mode 100644 packages/payments-engine/src/request-context.ts create mode 100644 packages/sdk-js/src/logger.ts create mode 100644 packages/sdk-js/src/request-context.ts create mode 100644 packages/subscriptions/src/logger.ts create mode 100644 packages/subscriptions/src/request-context.ts create mode 100644 scripts/observability/deploy-cloudwatch.ps1 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/src/app.controller.ts b/apps/api/src/app.controller.ts index 8f890d6..3c4b1cb 100644 --- a/apps/api/src/app.controller.ts +++ b/apps/api/src/app.controller.ts @@ -1,6 +1,11 @@ import { Controller, Get } from '@nestjs/common'; import { AppService } from './app.service'; import { Public } from './auth/decorators/public.decorator'; +import { apiLogger } from './observability'; + +const controllerLogger = apiLogger.child({ + controller: 'AppController', +}); @Controller() export class AppController { @@ -9,6 +14,10 @@ export class AppController { @Get() @Public() 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 72888f2..cab57aa 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'; @Module({ imports: [ @@ -36,4 +37,8 @@ import { ThrottlerRedisGuard } from './rate-limiter/guards/throttler-redis.guard }, ], }) -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 f76bc8d..e4fca82 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,8 +1,21 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; +import { apiLogger, LoggingExceptionFilter, RequestLoggingInterceptor } from './observability'; async function bootstrap() { - const app = await NestFactory.create(AppModule); - await app.listen(process.env.PORT ?? 3000); + const app = await NestFactory.create(AppModule, { + logger: false, + }); + + app.useGlobalInterceptors(new RequestLoggingInterceptor()); + app.useGlobalFilters(new LoggingExceptionFilter()); + + 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..5ccbc5b --- /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-serve-static-core' { + 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/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/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/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/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/src/index.ts b/packages/payments-engine/src/index.ts index e69de29..92c0130 100644 --- a/packages/payments-engine/src/index.ts +++ b/packages/payments-engine/src/index.ts @@ -0,0 +1,2 @@ +export * from './logger'; +export * from './request-context'; 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/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/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/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}