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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions apps/api/src/app.controller.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -9,6 +14,10 @@ export class AppController {
@Get()
@Public()
getHello(): string {
controllerLogger.info('Processing hello endpoint', {
event: 'hello_endpoint_requested',
});

return this.appService.getHello();
}
}
9 changes: 7 additions & 2 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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: [
Expand Down Expand Up @@ -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('*');
}
}
9 changes: 9 additions & 0 deletions apps/api/src/app.service.ts
Original file line number Diff line number Diff line change
@@ -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!';
}
}
9 changes: 9 additions & 0 deletions apps/api/src/health/health.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -20,6 +25,10 @@ export class HealthController {
@Public()
@HealthCheck()
check(): Promise<HealthCheckResult> {
controllerLogger.info('Running health checks', {
event: 'health_check_requested',
});

return this.health.check([
() => this.database.isHealthy('database'),
() => this.redis.isHealthy('redis'),
Expand Down
17 changes: 15 additions & 2 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
@@ -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();
5 changes: 5 additions & 0 deletions apps/api/src/observability/index.ts
Original file line number Diff line number Diff line change
@@ -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';
52 changes: 52 additions & 0 deletions apps/api/src/observability/logger.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
151 changes: 151 additions & 0 deletions apps/api/src/observability/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { getCorrelationId, getRequestContext } from './request-context';

export type LogLevel = 'debug' | 'info' | 'warn' | 'error';

const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
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<string, unknown>;

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<LogMetadata>((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',
});
36 changes: 36 additions & 0 deletions apps/api/src/observability/logging-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -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<Response>();
const request = http.getRequest<Request & { correlationId?: string }>();

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,
});
}
}
Loading
Loading