diff --git a/apps/api/src/SDK-Debug/index.ts b/apps/api/src/SDK-Debug/index.ts new file mode 100644 index 0000000..0c8f147 --- /dev/null +++ b/apps/api/src/SDK-Debug/index.ts @@ -0,0 +1,8 @@ +export * from './sdk-debug.constants'; +export * from './sdk-debug.types'; +export * from './sdk-debug.utils'; +export * from './sdk-debug.service'; +export * from './sdk-debug.interceptor'; +export * from './sdk-debug.middleware'; +export * from './sdk-debug.decorator'; +export * from './sdk-debug.module'; diff --git a/apps/api/src/SDK-Debug/sdk-debug.constants.ts b/apps/api/src/SDK-Debug/sdk-debug.constants.ts new file mode 100644 index 0000000..f32bd80 --- /dev/null +++ b/apps/api/src/SDK-Debug/sdk-debug.constants.ts @@ -0,0 +1,69 @@ +export const SDK_DEBUG_MODULE_OPTIONS = 'SDK_DEBUG_MODULE_OPTIONS'; +export const SDK_DEBUG_LOGGER = 'SDK_DEBUG_LOGGER'; + +export const SDK_DEBUG_LOG_LEVELS = { + VERBOSE: 'verbose', + DEBUG: 'debug', + INFO: 'info', + WARN: 'warn', + ERROR: 'error', +} as const; + +export const SDK_DEBUG_EVENTS = { + REQUEST_START: 'sdk.request.start', + REQUEST_END: 'sdk.request.end', + REQUEST_ERROR: 'sdk.request.error', + AUTH_ATTEMPT: 'sdk.auth.attempt', + AUTH_SUCCESS: 'sdk.auth.success', + AUTH_FAILURE: 'sdk.auth.failure', + RATE_LIMIT_HIT: 'sdk.rate_limit.hit', + RETRY_ATTEMPT: 'sdk.retry.attempt', + CACHE_HIT: 'sdk.cache.hit', + CACHE_MISS: 'sdk.cache.miss', + WEBHOOK_RECEIVED: 'sdk.webhook.received', + CIRCUIT_OPEN: 'sdk.circuit.open', + CIRCUIT_CLOSED: 'sdk.circuit.closed', +} as const; + +export const SDK_DEBUG_TRANSPORT = { + CONSOLE: 'console', + FILE: 'file', + HTTP: 'http', + CUSTOM: 'custom', +} as const; + +export const SDK_DEBUG_FORMAT = { + JSON: 'json', + PRETTY: 'pretty', + COMPACT: 'compact', +} as const; + +export const REDACTED_PLACEHOLDER = '[REDACTED]'; + +export const DEFAULT_SENSITIVE_KEYS = [ + 'password', + 'secret', + 'token', + 'apiKey', + 'api_key', + 'authorization', + 'Authorization', + 'x-api-key', + 'privateKey', + 'private_key', + 'accessToken', + 'access_token', + 'refreshToken', + 'refresh_token', + 'clientSecret', + 'client_secret', + 'passphrase', + 'mnemonic', + 'seed', + 'privateKeyBase58', + 'secretKeyBase64', +]; + +export const SDK_DEBUG_METADATA_KEY = 'sdk:debug'; +export const SDK_DEBUG_TRACE_ID_HEADER = 'x-sdk-trace-id'; +export const SDK_DEBUG_REQUEST_ID_HEADER = 'x-sdk-request-id'; diff --git a/apps/api/src/SDK-Debug/sdk-debug.decorator.ts b/apps/api/src/SDK-Debug/sdk-debug.decorator.ts new file mode 100644 index 0000000..b61a2b7 --- /dev/null +++ b/apps/api/src/SDK-Debug/sdk-debug.decorator.ts @@ -0,0 +1,30 @@ +import { SetMetadata, applyDecorators } from '@nestjs/common'; +import { SDK_DEBUG_METADATA_KEY } from './sdk-debug.constants'; + +export interface SdkDebugDecoratorOptions { + event?: string; + label?: string; + meta?: Record; + /** Suppress this particular call from debug output */ + suppress?: boolean; +} + +/** + * @SdkDebug() — attach debug metadata to a controller or route handler. + * The interceptor reads this metadata and enriches log entries accordingly. + * + * @example + * \@SdkDebug({ event: 'sdk.payment.create', label: 'CreatePayment' }) + * \@Post('payments') + * createPayment() { ... } + */ +export function SdkDebug(options: SdkDebugDecoratorOptions = {}) { + return applyDecorators(SetMetadata(SDK_DEBUG_METADATA_KEY, options)); +} + +/** + * @SdkDebugSuppress() — completely suppress debug output for this route. + */ +export function SdkDebugSuppress() { + return SdkDebug({ suppress: true }); +} diff --git a/apps/api/src/SDK-Debug/sdk-debug.integration.spec.ts b/apps/api/src/SDK-Debug/sdk-debug.integration.spec.ts new file mode 100644 index 0000000..9f0a1e5 --- /dev/null +++ b/apps/api/src/SDK-Debug/sdk-debug.integration.spec.ts @@ -0,0 +1,150 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SdkDebugModule } from '../src/sdk-debug.module'; +import { SdkDebugService } from '../src/sdk-debug.service'; +import { SdkDebugInterceptor } from '../src/sdk-debug.interceptor'; +import { SdkDebugMiddleware } from '../src/sdk-debug.middleware'; +import { SdkDebugLogEntry, SdkDebugModuleOptions } from '../src/sdk-debug.types'; +import { SDK_DEBUG_TRANSPORT } from '../src/sdk-debug.constants'; + +// suppress console noise +jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + +describe('SdkDebugModule — integration', () => { + let module: TestingModule; + let service: SdkDebugService; + const captured: SdkDebugLogEntry[] = []; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + SdkDebugModule.forRoot({ + enabled: true, + level: 'verbose', + namespace: 'BridgeWise-Test', + colorize: false, + includeSystemInfo: true, + includeMemoryUsage: true, + includeStackTrace: true, + globalMeta: { env: 'test' }, + sensitiveKeys: ['password', 'apiKey'], + transports: [ + { + type: SDK_DEBUG_TRANSPORT.CUSTOM, + handler: (e) => captured.push(e), + }, + ], + }), + ], + }).compile(); + + service = module.get(SdkDebugService); + }); + + afterAll(() => module.close()); + afterEach(() => captured.splice(0)); + + it('provides SdkDebugService', () => { + expect(service).toBeInstanceOf(SdkDebugService); + }); + + it('provides SdkDebugInterceptor', () => { + expect(module.get(SdkDebugInterceptor)).toBeInstanceOf(SdkDebugInterceptor); + }); + + it('provides SdkDebugMiddleware', () => { + expect(module.get(SdkDebugMiddleware)).toBeInstanceOf(SdkDebugMiddleware); + }); + + it('writes logs to custom transport', () => { + service.info('test.integration', 'hello from integration test'); + expect(captured).toHaveLength(1); + expect(captured[0].namespace).toBe('BridgeWise-Test'); + expect(captured[0].event).toBe('test.integration'); + }); + + it('redacts sensitive keys in integration context', () => { + service.debug('test.auth', 'login attempt', { + username: 'alice', + password: 'hunter2', + }); + expect(captured[0].meta?.password).toBe('[REDACTED]'); + expect(captured[0].meta?.username).toBe('alice'); + }); + + it('attaches globalMeta to every entry', () => { + service.debug('x', 'msg'); + expect(captured[0].meta?.env).toBe('test'); + }); + + it('attaches systemInfo when configured', () => { + service.debug('x', 'msg'); + expect(captured[0].system).toBeDefined(); + expect(captured[0].system?.pid).toBe(process.pid); + }); + + it('attaches memoryInfo when configured', () => { + service.debug('x', 'msg'); + expect(captured[0].memory).toBeDefined(); + expect(captured[0].memory?.heapUsedMB).toBeGreaterThanOrEqual(0); + }); + + it('trace() captures success result', async () => { + const result = await service.trace('test.trace', 'AddNumbers', async () => 1 + 1); + expect(result).toBe(2); + // 2 entries: start + end + expect(captured).toHaveLength(2); + const endEntry = captured[1]; + expect(endEntry.meta?.success).toBe(true); + }); + + it('trace() captures error and rethrows', async () => { + await expect( + service.trace('test.trace.fail', 'BadOp', async () => { + throw new Error('intentional'); + }), + ).rejects.toThrow('intentional'); + // 3 entries: start + end + error + expect(captured.length).toBeGreaterThanOrEqual(2); + expect(captured.some((e) => e.error !== undefined)).toBe(true); + }); + + it('getStats() aggregates across multiple calls', () => { + service.info('a', '1'); + service.info('b', '2'); + service.error('c', '3'); + const stats = service.getStats(); + expect(stats.totalLogs).toBeGreaterThanOrEqual(3); + expect(stats.logsByLevel.info).toBeGreaterThanOrEqual(2); + expect(stats.logsByLevel.error).toBeGreaterThanOrEqual(1); + }); +}); + +// --------------------------------------------------------------------------- +// forRootAsync test +// --------------------------------------------------------------------------- +describe('SdkDebugModule.forRootAsync', () => { + it('registers via useFactory', async () => { + const mod = await Test.createTestingModule({ + imports: [ + SdkDebugModule.forRootAsync({ + useFactory: (): SdkDebugModuleOptions => ({ + enabled: true, + level: 'info', + namespace: 'async-test', + }), + }), + ], + }).compile(); + + const svc = mod.get(SdkDebugService); + expect(svc).toBeInstanceOf(SdkDebugService); + expect(svc.isEnabled).toBe(true); + await mod.close(); + }); + + it('throws if no factory/class/existing provided', () => { + expect(() => + SdkDebugModule.forRootAsync({} as never), + ).toThrow(); + }); +}); diff --git a/apps/api/src/SDK-Debug/sdk-debug.interceptor.spec.ts b/apps/api/src/SDK-Debug/sdk-debug.interceptor.spec.ts new file mode 100644 index 0000000..452ece6 --- /dev/null +++ b/apps/api/src/SDK-Debug/sdk-debug.interceptor.spec.ts @@ -0,0 +1,185 @@ +import { CallHandler, ExecutionContext } from '@nestjs/common'; +import { of, throwError } from 'rxjs'; +import { SdkDebugInterceptor } from '../src/sdk-debug.interceptor'; +import { SdkDebugService } from '../src/sdk-debug.service'; +import { SDK_DEBUG_TRACE_ID_HEADER } from '../src/sdk-debug.constants'; + +function mockHttpContext( + method = 'GET', + url = '/test', + headers: Record = {}, +): ExecutionContext { + const req = { + method, + url, + headers: { 'user-agent': 'jest', ...headers }, + query: {}, + body: {}, + ip: '127.0.0.1', + }; + const res = { + statusCode: 200, + setHeader: jest.fn(), + }; + return { + getType: () => 'http', + switchToHttp: () => ({ + getRequest: () => req, + getResponse: () => res, + }), + } as unknown as ExecutionContext; +} + +function mockRpcContext(): ExecutionContext { + return { + getType: () => 'rpc', + } as unknown as ExecutionContext; +} + +describe('SdkDebugInterceptor', () => { + let interceptor: SdkDebugInterceptor; + let debugService: jest.Mocked; + + beforeEach(() => { + debugService = { + isEnabled: true, + debug: jest.fn(), + error: jest.fn(), + } as unknown as jest.Mocked; + + interceptor = new SdkDebugInterceptor(debugService); + }); + + describe('HTTP context', () => { + it('logs request start', (done) => { + const ctx = mockHttpContext('GET', '/users'); + const handler: CallHandler = { handle: () => of({ users: [] }) }; + + interceptor.intercept(ctx, handler).subscribe({ + complete: () => { + expect(debugService.debug).toHaveBeenCalledWith( + expect.stringContaining('request'), + expect.stringContaining('GET'), + expect.objectContaining({ method: 'GET', url: '/users' }), + ); + done(); + }, + }); + }); + + it('logs request end on success', (done) => { + const ctx = mockHttpContext('POST', '/payments'); + const handler: CallHandler = { handle: () => of({ id: 'pay-1' }) }; + + interceptor.intercept(ctx, handler).subscribe({ + complete: () => { + // Should have 2 debug calls: start + end + expect(debugService.debug).toHaveBeenCalledTimes(2); + done(); + }, + }); + }); + + it('logs error on failure', (done) => { + const ctx = mockHttpContext('DELETE', '/resource/1'); + const err = new Error('not found'); + const handler: CallHandler = { handle: () => throwError(() => err) }; + + interceptor.intercept(ctx, handler).subscribe({ + error: () => { + expect(debugService.error).toHaveBeenCalledWith( + expect.stringContaining('error'), + expect.any(String), + err, + expect.objectContaining({ url: '/resource/1' }), + ); + done(); + }, + }); + }); + + it('sets trace/request ID headers on response', (done) => { + const ctx = mockHttpContext(); + const res = ctx.switchToHttp().getResponse() as { setHeader: jest.Mock }; + const handler: CallHandler = { handle: () => of({}) }; + + interceptor.intercept(ctx, handler).subscribe({ + complete: () => { + expect(res.setHeader).toHaveBeenCalledWith( + SDK_DEBUG_TRACE_ID_HEADER, + expect.any(String), + ); + done(); + }, + }); + }); + + it('does not re-generate trace ID when header already present', (done) => { + const existingTrace = 'existing-trace-id'; + const ctx = mockHttpContext('GET', '/ping', { + [SDK_DEBUG_TRACE_ID_HEADER]: existingTrace, + }); + const res = ctx.switchToHttp().getResponse() as { setHeader: jest.Mock }; + const handler: CallHandler = { handle: () => of({}) }; + + interceptor.intercept(ctx, handler).subscribe({ + complete: () => { + expect(res.setHeader).toHaveBeenCalledWith( + SDK_DEBUG_TRACE_ID_HEADER, + existingTrace, + ); + done(); + }, + }); + }); + + it('does not include Authorization in logged headers', (done) => { + const ctx = mockHttpContext('GET', '/secure', { + authorization: 'Bearer secret-token', + }); + const handler: CallHandler = { handle: () => of({}) }; + + interceptor.intercept(ctx, handler).subscribe({ + complete: () => { + const startCall = (debugService.debug as jest.Mock).mock.calls[0]; + const meta = startCall[2] as Record; + const headers = meta.headers as Record; + expect(headers).not.toHaveProperty('authorization'); + expect(headers).not.toHaveProperty('Authorization'); + done(); + }, + }); + }); + }); + + describe('Non-HTTP context', () => { + it('passes through RPC calls without logging', (done) => { + const ctx = mockRpcContext(); + const handler: CallHandler = { handle: () => of({ rpcResult: true }) }; + + interceptor.intercept(ctx, handler).subscribe({ + next: (val) => { + expect(val).toEqual({ rpcResult: true }); + expect(debugService.debug).not.toHaveBeenCalled(); + done(); + }, + }); + }); + }); + + describe('when disabled', () => { + it('passes through without logging', (done) => { + (debugService as { isEnabled: boolean }).isEnabled = false; + const ctx = mockHttpContext(); + const handler: CallHandler = { handle: () => of({ data: 1 }) }; + + interceptor.intercept(ctx, handler).subscribe({ + next: (val) => { + expect(val).toEqual({ data: 1 }); + expect(debugService.debug).not.toHaveBeenCalled(); + done(); + }, + }); + }); + }); +}); diff --git a/apps/api/src/SDK-Debug/sdk-debug.interceptor.ts b/apps/api/src/SDK-Debug/sdk-debug.interceptor.ts new file mode 100644 index 0000000..2698641 --- /dev/null +++ b/apps/api/src/SDK-Debug/sdk-debug.interceptor.ts @@ -0,0 +1,119 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable, throwError } from 'rxjs'; +import { catchError, tap } from 'rxjs/operators'; +import { Request, Response } from 'express'; + +import { + SDK_DEBUG_EVENTS, + SDK_DEBUG_TRACE_ID_HEADER, + SDK_DEBUG_REQUEST_ID_HEADER, +} from './sdk-debug.constants'; +import { SdkDebugService } from './sdk-debug.service'; +import { generateId } from './sdk-debug.utils'; + +@Injectable() +export class SdkDebugInterceptor implements NestInterceptor { + constructor(private readonly debugService: SdkDebugService) {} + + intercept(ctx: ExecutionContext, next: CallHandler): Observable { + if (!this.debugService.isEnabled) return next.handle(); + + const type = ctx.getType<'http' | 'rpc' | 'ws'>(); + + if (type === 'http') { + return this.handleHttp(ctx, next); + } + + // For RPC / WS just pass through + return next.handle(); + } + + private handleHttp( + ctx: ExecutionContext, + next: CallHandler, + ): Observable { + const req = ctx.switchToHttp().getRequest(); + const res = ctx.switchToHttp().getResponse(); + + const traceId = + (req.headers[SDK_DEBUG_TRACE_ID_HEADER] as string) ?? + generateId('trace'); + const requestId = + (req.headers[SDK_DEBUG_REQUEST_ID_HEADER] as string) ?? + generateId('req'); + + // Echo trace ID back in response headers + res.setHeader(SDK_DEBUG_TRACE_ID_HEADER, traceId); + res.setHeader(SDK_DEBUG_REQUEST_ID_HEADER, requestId); + + const startTime = Date.now(); + + this.debugService.debug( + SDK_DEBUG_EVENTS.REQUEST_START, + `→ ${req.method} ${req.url}`, + { + traceId, + requestId, + method: req.method, + url: req.url, + query: req.query, + headers: this.safeHeaders(req.headers), + body: req.body, + ip: req.ip, + userAgent: req.headers['user-agent'], + }, + ); + + return next.handle().pipe( + tap(() => { + const duration = Date.now() - startTime; + this.debugService.debug( + SDK_DEBUG_EVENTS.REQUEST_END, + `← ${req.method} ${req.url} [${res.statusCode}] ${duration}ms`, + { + traceId, + requestId, + method: req.method, + url: req.url, + statusCode: res.statusCode, + duration, + }, + ); + }), + catchError((err: Error) => { + const duration = Date.now() - startTime; + this.debugService.error( + SDK_DEBUG_EVENTS.REQUEST_ERROR, + `✖ ${req.method} ${req.url} FAILED after ${duration}ms`, + err, + { + traceId, + requestId, + method: req.method, + url: req.url, + duration, + }, + ); + return throwError(() => err); + }), + ); + } + + /** Strip Authorization header but keep everything else */ + private safeHeaders( + headers: Record, + ): Record { + const { authorization, Authorization, ...rest } = headers as Record< + string, + string | string[] | undefined + >; + void authorization; + void Authorization; + return rest; + } +} diff --git a/apps/api/src/SDK-Debug/sdk-debug.middleware.spec.ts b/apps/api/src/SDK-Debug/sdk-debug.middleware.spec.ts new file mode 100644 index 0000000..2cb3ac4 --- /dev/null +++ b/apps/api/src/SDK-Debug/sdk-debug.middleware.spec.ts @@ -0,0 +1,96 @@ +import { SdkDebugMiddleware } from '../src/sdk-debug.middleware'; +import { + SDK_DEBUG_REQUEST_ID_HEADER, + SDK_DEBUG_TRACE_ID_HEADER, +} from '../src/sdk-debug.constants'; +import { Request, Response, NextFunction } from 'express'; + +function buildReq( + headers: Record = {}, +): Partial & { headers: Record } { + return { headers }; +} + +function buildRes(): Partial & { setHeader: jest.Mock } { + return { setHeader: jest.fn() }; +} + +describe('SdkDebugMiddleware', () => { + let middleware: SdkDebugMiddleware; + let next: NextFunction; + + beforeEach(() => { + middleware = new SdkDebugMiddleware(); + next = jest.fn(); + }); + + it('calls next()', () => { + const req = buildReq(); + const res = buildRes(); + middleware.use(req as Request, res as Response, next); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('generates a traceId when header is absent', () => { + const req = buildReq(); + const res = buildRes(); + middleware.use(req as Request, res as Response, next); + expect(req.headers[SDK_DEBUG_TRACE_ID_HEADER]).toBeDefined(); + expect(typeof req.headers[SDK_DEBUG_TRACE_ID_HEADER]).toBe('string'); + expect((req.headers[SDK_DEBUG_TRACE_ID_HEADER] as string).length).toBeGreaterThan(0); + }); + + it('generates a requestId when header is absent', () => { + const req = buildReq(); + const res = buildRes(); + middleware.use(req as Request, res as Response, next); + expect(req.headers[SDK_DEBUG_REQUEST_ID_HEADER]).toBeDefined(); + }); + + it('preserves an existing traceId', () => { + const existingId = 'my-custom-trace'; + const req = buildReq({ [SDK_DEBUG_TRACE_ID_HEADER]: existingId }); + const res = buildRes(); + middleware.use(req as Request, res as Response, next); + expect(req.headers[SDK_DEBUG_TRACE_ID_HEADER]).toBe(existingId); + }); + + it('preserves an existing requestId', () => { + const existingReqId = 'my-custom-req-id'; + const req = buildReq({ [SDK_DEBUG_REQUEST_ID_HEADER]: existingReqId }); + const res = buildRes(); + middleware.use(req as Request, res as Response, next); + expect(req.headers[SDK_DEBUG_REQUEST_ID_HEADER]).toBe(existingReqId); + }); + + it('echoes traceId onto response headers', () => { + const req = buildReq(); + const res = buildRes(); + middleware.use(req as Request, res as Response, next); + expect(res.setHeader).toHaveBeenCalledWith( + SDK_DEBUG_TRACE_ID_HEADER, + req.headers[SDK_DEBUG_TRACE_ID_HEADER], + ); + }); + + it('echoes requestId onto response headers', () => { + const req = buildReq(); + const res = buildRes(); + middleware.use(req as Request, res as Response, next); + expect(res.setHeader).toHaveBeenCalledWith( + SDK_DEBUG_REQUEST_ID_HEADER, + req.headers[SDK_DEBUG_REQUEST_ID_HEADER], + ); + }); + + it('generates unique ids on every call', () => { + const ids = new Set(); + for (let i = 0; i < 50; i++) { + const req = buildReq(); + const res = buildRes(); + middleware.use(req as Request, res as Response, next); + ids.add(req.headers[SDK_DEBUG_TRACE_ID_HEADER]); + } + expect(ids.size).toBe(50); + }); +}); diff --git a/apps/api/src/SDK-Debug/sdk-debug.middleware.ts b/apps/api/src/SDK-Debug/sdk-debug.middleware.ts new file mode 100644 index 0000000..e5a41b3 --- /dev/null +++ b/apps/api/src/SDK-Debug/sdk-debug.middleware.ts @@ -0,0 +1,41 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { + SDK_DEBUG_REQUEST_ID_HEADER, + SDK_DEBUG_TRACE_ID_HEADER, +} from './sdk-debug.constants'; +import { generateId } from './sdk-debug.utils'; + +/** + * Middleware that ensures every incoming request has a traceId and requestId. + * Apply globally in AppModule or selectively per route group. + * + * @example + * // app.module.ts + * configure(consumer: MiddlewareConsumer) { + * consumer.apply(SdkDebugMiddleware).forRoutes('*'); + * } + */ +@Injectable() +export class SdkDebugMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: NextFunction): void { + if (!req.headers[SDK_DEBUG_TRACE_ID_HEADER]) { + req.headers[SDK_DEBUG_TRACE_ID_HEADER] = generateId('trace'); + } + if (!req.headers[SDK_DEBUG_REQUEST_ID_HEADER]) { + req.headers[SDK_DEBUG_REQUEST_ID_HEADER] = generateId('req'); + } + + // Expose on res headers too + res.setHeader( + SDK_DEBUG_TRACE_ID_HEADER, + req.headers[SDK_DEBUG_TRACE_ID_HEADER] as string, + ); + res.setHeader( + SDK_DEBUG_REQUEST_ID_HEADER, + req.headers[SDK_DEBUG_REQUEST_ID_HEADER] as string, + ); + + next(); + } +} diff --git a/apps/api/src/SDK-Debug/sdk-debug.module.ts b/apps/api/src/SDK-Debug/sdk-debug.module.ts new file mode 100644 index 0000000..a555526 --- /dev/null +++ b/apps/api/src/SDK-Debug/sdk-debug.module.ts @@ -0,0 +1,112 @@ +import { DynamicModule, Global, Module, Provider, Type } from '@nestjs/common'; +import { + SDK_DEBUG_MODULE_OPTIONS, +} from './sdk-debug.constants'; +import { + SdkDebugModuleAsyncOptions, + SdkDebugModuleOptions, + SdkDebugOptionsFactory, +} from './sdk-debug.types'; +import { SdkDebugService } from './sdk-debug.service'; +import { SdkDebugInterceptor } from './sdk-debug.interceptor'; +import { SdkDebugMiddleware } from './sdk-debug.middleware'; + +@Global() +@Module({}) +export class SdkDebugModule { + /** + * Synchronous registration. + * + * @example + * SdkDebugModule.forRoot({ + * enabled: process.env.NODE_ENV !== 'production', + * level: 'debug', + * namespace: 'BridgeWise', + * colorize: true, + * prettyPrint: true, + * includeStackTrace: true, + * }) + */ + static forRoot(options: SdkDebugModuleOptions): DynamicModule { + return { + module: SdkDebugModule, + providers: [ + { provide: SDK_DEBUG_MODULE_OPTIONS, useValue: options }, + SdkDebugService, + SdkDebugInterceptor, + SdkDebugMiddleware, + ], + exports: [SdkDebugService, SdkDebugInterceptor, SdkDebugMiddleware], + }; + } + + /** + * Async registration — use when options come from ConfigService or env. + * + * @example + * SdkDebugModule.forRootAsync({ + * imports: [ConfigModule], + * useFactory: (cfg: ConfigService) => ({ + * enabled: cfg.get('SDK_DEBUG_ENABLED', false), + * level: cfg.get('SDK_DEBUG_LEVEL', 'debug'), + * namespace: cfg.get('SDK_DEBUG_NAMESPACE', 'BridgeWise'), + * }), + * inject: [ConfigService], + * }) + */ + static forRootAsync(options: SdkDebugModuleAsyncOptions): DynamicModule { + const asyncProviders = SdkDebugModule.createAsyncProviders(options); + + return { + module: SdkDebugModule, + imports: options.imports ?? [], + providers: [ + ...asyncProviders, + SdkDebugService, + SdkDebugInterceptor, + SdkDebugMiddleware, + ], + exports: [SdkDebugService, SdkDebugInterceptor, SdkDebugMiddleware], + }; + } + + private static createAsyncProviders( + options: SdkDebugModuleAsyncOptions, + ): Provider[] { + if (options.useFactory) { + return [ + { + provide: SDK_DEBUG_MODULE_OPTIONS, + useFactory: options.useFactory, + inject: (options.inject ?? []) as never[], + }, + ]; + } + + const useClass = options.useClass ?? options.useExisting; + + if (useClass) { + const providers: Provider[] = [ + { + provide: SDK_DEBUG_MODULE_OPTIONS, + useFactory: async (factory: SdkDebugOptionsFactory) => + factory.createSdkDebugOptions(), + inject: [useClass], + }, + ]; + + if (options.useClass) { + providers.push({ + provide: useClass as Type, + useClass: useClass as Type, + }); + } + + return providers; + } + + throw new Error( + 'SdkDebugModule: provide useFactory, useClass, or useExisting', + ); + } +} diff --git a/apps/api/src/SDK-Debug/sdk-debug.service.spec (1).ts b/apps/api/src/SDK-Debug/sdk-debug.service.spec (1).ts new file mode 100644 index 0000000..6bf0ab4 --- /dev/null +++ b/apps/api/src/SDK-Debug/sdk-debug.service.spec (1).ts @@ -0,0 +1,401 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SdkDebugService } from '../src/sdk-debug.service'; +import { + SDK_DEBUG_MODULE_OPTIONS, + SDK_DEBUG_EVENTS, + SDK_DEBUG_TRANSPORT, +} from '../src/sdk-debug.constants'; +import { SdkDebugModuleOptions, SdkDebugLogEntry } from '../src/sdk-debug.types'; + +// Silence stdout during tests +const stdoutSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + +afterAll(() => stdoutSpy.mockRestore()); + +function buildModule(overrides: Partial = {}) { + const defaults: SdkDebugModuleOptions = { + enabled: true, + level: 'verbose', + colorize: false, + prettyPrint: false, + }; + + return Test.createTestingModule({ + providers: [ + { provide: SDK_DEBUG_MODULE_OPTIONS, useValue: { ...defaults, ...overrides } }, + SdkDebugService, + ], + }).compile(); +} + +describe('SdkDebugService', () => { + let module: TestingModule; + let service: SdkDebugService; + + beforeEach(async () => { + stdoutSpy.mockClear(); + module = await buildModule(); + service = module.get(SdkDebugService); + }); + + afterEach(() => module.close()); + + // --------------------------------------------------------------------------- + // Basic enabled/disabled + // --------------------------------------------------------------------------- + describe('enabled flag', () => { + it('emits logs when enabled', () => { + service.info(SDK_DEBUG_EVENTS.REQUEST_START, 'test message'); + expect(stdoutSpy).toHaveBeenCalled(); + }); + + it('emits nothing when disabled', async () => { + const disabledModule = await buildModule({ enabled: false }); + const disabledService = disabledModule.get(SdkDebugService); + disabledService.info(SDK_DEBUG_EVENTS.REQUEST_START, 'should not appear'); + expect(stdoutSpy).not.toHaveBeenCalled(); + await disabledModule.close(); + }); + + it('isEnabled reflects config', async () => { + expect(service.isEnabled).toBe(true); + const off = await buildModule({ enabled: false }); + expect(off.get(SdkDebugService).isEnabled).toBe(false); + await off.close(); + }); + }); + + // --------------------------------------------------------------------------- + // Log level methods + // --------------------------------------------------------------------------- + describe('log level methods', () => { + const methods = ['verbose', 'debug', 'info', 'warn'] as const; + methods.forEach((method) => { + it(`${method}() calls emit and writes to stdout`, () => { + service[method]('test.event', `${method} message`); + expect(stdoutSpy).toHaveBeenCalledTimes(1); + }); + }); + + it('error() includes error details in log entry', () => { + const err = new Error('disk full'); + service.error('test.error', 'Something broke', err); + const output = stdoutSpy.mock.calls[0][0] as string; + expect(output).toContain('disk full'); + }); + + it('error() handles non-Error objects gracefully', () => { + expect(() => + service.error('test.error', 'oops', 'string error'), + ).not.toThrow(); + }); + }); + + // --------------------------------------------------------------------------- + // Level filtering + // --------------------------------------------------------------------------- + describe('level filtering', () => { + it('suppresses entries below min level', async () => { + const mod = await buildModule({ level: 'warn' }); + const svc = mod.get(SdkDebugService); + svc.debug('test.event', 'should be filtered'); + expect(stdoutSpy).not.toHaveBeenCalled(); + await mod.close(); + }); + + it('allows entries at or above min level', async () => { + const mod = await buildModule({ level: 'warn' }); + const svc = mod.get(SdkDebugService); + svc.error('test.event', 'should appear'); + expect(stdoutSpy).toHaveBeenCalled(); + await mod.close(); + }); + }); + + // --------------------------------------------------------------------------- + // Sensitive key redaction + // --------------------------------------------------------------------------- + describe('sensitive key redaction', () => { + it('redacts configured sensitive keys from meta', () => { + service.debug('test.event', 'msg', { + apiKey: 'super-secret', + safeField: 'visible', + }); + const output = stdoutSpy.mock.calls[0][0] as string; + expect(output).not.toContain('super-secret'); + expect(output).toContain('[REDACTED]'); + }); + + it('keeps non-sensitive keys intact', () => { + service.debug('test.event', 'msg', { userId: 'user-123' }); + const output = stdoutSpy.mock.calls[0][0] as string; + expect(output).toContain('user-123'); + }); + }); + + // --------------------------------------------------------------------------- + // Suppressed events + // --------------------------------------------------------------------------- + describe('suppressedEvents', () => { + it('suppresses matching events', async () => { + const mod = await buildModule({ + suppressedEvents: ['sdk.noisy.event'], + }); + const svc = mod.get(SdkDebugService); + svc.debug('sdk.noisy.event', 'should not appear'); + expect(stdoutSpy).not.toHaveBeenCalled(); + await mod.close(); + }); + }); + + // --------------------------------------------------------------------------- + // Global meta + // --------------------------------------------------------------------------- + describe('globalMeta', () => { + it('attaches global meta to every log entry', () => { + // We test this by checking the stdout output contains our global meta key + // We'll use a custom transport to capture the entry directly + const entries: SdkDebugLogEntry[] = []; + const mod = Test.createTestingModule({ + providers: [ + { + provide: SDK_DEBUG_MODULE_OPTIONS, + useValue: { + enabled: true, + level: 'verbose', + globalMeta: { service: 'BridgeWise', version: '1.0.0' }, + transports: [ + { + type: SDK_DEBUG_TRANSPORT.CUSTOM, + handler: (e: SdkDebugLogEntry) => entries.push(e), + }, + ], + } satisfies SdkDebugModuleOptions, + }, + SdkDebugService, + ], + }).compile(); + + return mod.then(async (m) => { + const svc = m.get(SdkDebugService); + svc.debug('some.event', 'hello'); + expect(entries).toHaveLength(1); + expect(entries[0].meta?.service).toBe('BridgeWise'); + expect(entries[0].meta?.version).toBe('1.0.0'); + await m.close(); + }); + }); + }); + + // --------------------------------------------------------------------------- + // Custom transport + // --------------------------------------------------------------------------- + describe('custom transport', () => { + it('calls the custom handler with the log entry', async () => { + const captured: SdkDebugLogEntry[] = []; + + const mod = await Test.createTestingModule({ + providers: [ + { + provide: SDK_DEBUG_MODULE_OPTIONS, + useValue: { + enabled: true, + level: 'verbose', + transports: [ + { + type: SDK_DEBUG_TRANSPORT.CUSTOM, + handler: (entry: SdkDebugLogEntry) => captured.push(entry), + }, + ], + } satisfies SdkDebugModuleOptions, + }, + SdkDebugService, + ], + }).compile(); + + const svc = mod.get(SdkDebugService); + svc.info('sdk.payment.create', 'Payment initiated', { amount: 100 }); + + expect(captured).toHaveLength(1); + expect(captured[0].event).toBe('sdk.payment.create'); + expect(captured[0].message).toBe('Payment initiated'); + expect(captured[0].meta?.amount).toBe(100); + expect(captured[0].level).toBe('info'); + await mod.close(); + }); + }); + + // --------------------------------------------------------------------------- + // time() helper + // --------------------------------------------------------------------------- + describe('time()', () => { + it('returns a finish function', () => { + const finish = service.time(SDK_DEBUG_EVENTS.REQUEST_START, 'FetchUser'); + expect(typeof finish).toBe('function'); + }); + + it('logs start and end entries', () => { + const finish = service.time(SDK_DEBUG_EVENTS.REQUEST_END, 'FetchUser'); + finish(); + expect(stdoutSpy).toHaveBeenCalledTimes(2); // start + end + }); + }); + + // --------------------------------------------------------------------------- + // trace() helper + // --------------------------------------------------------------------------- + describe('trace()', () => { + it('resolves and logs success', async () => { + const result = await service.trace( + SDK_DEBUG_EVENTS.REQUEST_END, + 'GetBalance', + async () => ({ balance: 500 }), + ); + expect(result).toEqual({ balance: 500 }); + expect(stdoutSpy).toHaveBeenCalledTimes(2); // start + end + }); + + it('rethrows errors and logs failure', async () => { + await expect( + service.trace( + SDK_DEBUG_EVENTS.REQUEST_ERROR, + 'FailingOp', + async () => { + throw new Error('intentional failure'); + }, + ), + ).rejects.toThrow('intentional failure'); + + // start + end + error = 3 writes + expect(stdoutSpy.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + }); + + // --------------------------------------------------------------------------- + // getStats() + // --------------------------------------------------------------------------- + describe('getStats()', () => { + it('tracks total log count', () => { + service.debug('a', 'msg 1'); + service.info('b', 'msg 2'); + service.error('c', 'msg 3'); + const stats = service.getStats(); + expect(stats.totalLogs).toBe(3); + }); + + it('tracks per-level counts', () => { + service.warn('x', 'warn msg'); + const stats = service.getStats(); + expect(stats.logsByLevel.warn).toBeGreaterThanOrEqual(1); + }); + + it('tracks per-event counts', () => { + service.debug(SDK_DEBUG_EVENTS.CACHE_HIT, 'cache hit 1'); + service.debug(SDK_DEBUG_EVENTS.CACHE_HIT, 'cache hit 2'); + const stats = service.getStats(); + expect(stats.logsByEvent[SDK_DEBUG_EVENTS.CACHE_HIT]).toBe(2); + }); + + it('tracks error rate', () => { + service.info('event', 'ok'); + service.info('event', 'ok'); + service.error('event', 'fail'); + const stats = service.getStats(); + // 1 error out of 3 total + expect(stats.errorRate).toBeCloseTo(1 / 3, 2); + }); + + it('returns a copy (immutable snapshot)', () => { + const stats1 = service.getStats(); + service.info('extra.event', 'extra'); + const stats2 = service.getStats(); + expect(stats2.totalLogs).toBeGreaterThan(stats1.totalLogs); + }); + }); + + // --------------------------------------------------------------------------- + // Format variants + // --------------------------------------------------------------------------- + describe('format variants', () => { + it('JSON format outputs valid JSON per line', async () => { + const mod = await buildModule({ format: 'json' }); + const svc = mod.get(SdkDebugService); + svc.info('test.event', 'json format test'); + const output = (stdoutSpy.mock.calls[0][0] as string).trim(); + expect(() => JSON.parse(output)).not.toThrow(); + await mod.close(); + }); + + it('compact format outputs a single line', async () => { + const mod = await buildModule({ format: 'compact' }); + const svc = mod.get(SdkDebugService); + svc.info('test.event', 'compact format test'); + const output = (stdoutSpy.mock.calls[0][0] as string).trim(); + expect(output.includes('\n')).toBe(false); + await mod.close(); + }); + }); + + // --------------------------------------------------------------------------- + // System / memory info + // --------------------------------------------------------------------------- + describe('system and memory info', () => { + it('includes system info when configured', async () => { + const entries: SdkDebugLogEntry[] = []; + const mod = await Test.createTestingModule({ + providers: [ + { + provide: SDK_DEBUG_MODULE_OPTIONS, + useValue: { + enabled: true, + level: 'verbose', + includeSystemInfo: true, + transports: [ + { + type: SDK_DEBUG_TRANSPORT.CUSTOM, + handler: (e: SdkDebugLogEntry) => entries.push(e), + }, + ], + } satisfies SdkDebugModuleOptions, + }, + SdkDebugService, + ], + }).compile(); + + mod.get(SdkDebugService).info('event', 'msg'); + expect(entries[0].system).toBeDefined(); + expect(entries[0].system?.pid).toBe(process.pid); + await mod.close(); + }); + + it('includes memory info when configured', async () => { + const entries: SdkDebugLogEntry[] = []; + const mod = await Test.createTestingModule({ + providers: [ + { + provide: SDK_DEBUG_MODULE_OPTIONS, + useValue: { + enabled: true, + level: 'verbose', + includeMemoryUsage: true, + transports: [ + { + type: SDK_DEBUG_TRANSPORT.CUSTOM, + handler: (e: SdkDebugLogEntry) => entries.push(e), + }, + ], + } satisfies SdkDebugModuleOptions, + }, + SdkDebugService, + ], + }).compile(); + + mod.get(SdkDebugService).info('event', 'msg'); + expect(entries[0].memory).toBeDefined(); + expect(typeof entries[0].memory?.heapUsedMB).toBe('number'); + await mod.close(); + }); + }); +}); diff --git a/apps/api/src/SDK-Debug/sdk-debug.service.spec.ts b/apps/api/src/SDK-Debug/sdk-debug.service.spec.ts new file mode 100644 index 0000000..6bf0ab4 --- /dev/null +++ b/apps/api/src/SDK-Debug/sdk-debug.service.spec.ts @@ -0,0 +1,401 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SdkDebugService } from '../src/sdk-debug.service'; +import { + SDK_DEBUG_MODULE_OPTIONS, + SDK_DEBUG_EVENTS, + SDK_DEBUG_TRANSPORT, +} from '../src/sdk-debug.constants'; +import { SdkDebugModuleOptions, SdkDebugLogEntry } from '../src/sdk-debug.types'; + +// Silence stdout during tests +const stdoutSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + +afterAll(() => stdoutSpy.mockRestore()); + +function buildModule(overrides: Partial = {}) { + const defaults: SdkDebugModuleOptions = { + enabled: true, + level: 'verbose', + colorize: false, + prettyPrint: false, + }; + + return Test.createTestingModule({ + providers: [ + { provide: SDK_DEBUG_MODULE_OPTIONS, useValue: { ...defaults, ...overrides } }, + SdkDebugService, + ], + }).compile(); +} + +describe('SdkDebugService', () => { + let module: TestingModule; + let service: SdkDebugService; + + beforeEach(async () => { + stdoutSpy.mockClear(); + module = await buildModule(); + service = module.get(SdkDebugService); + }); + + afterEach(() => module.close()); + + // --------------------------------------------------------------------------- + // Basic enabled/disabled + // --------------------------------------------------------------------------- + describe('enabled flag', () => { + it('emits logs when enabled', () => { + service.info(SDK_DEBUG_EVENTS.REQUEST_START, 'test message'); + expect(stdoutSpy).toHaveBeenCalled(); + }); + + it('emits nothing when disabled', async () => { + const disabledModule = await buildModule({ enabled: false }); + const disabledService = disabledModule.get(SdkDebugService); + disabledService.info(SDK_DEBUG_EVENTS.REQUEST_START, 'should not appear'); + expect(stdoutSpy).not.toHaveBeenCalled(); + await disabledModule.close(); + }); + + it('isEnabled reflects config', async () => { + expect(service.isEnabled).toBe(true); + const off = await buildModule({ enabled: false }); + expect(off.get(SdkDebugService).isEnabled).toBe(false); + await off.close(); + }); + }); + + // --------------------------------------------------------------------------- + // Log level methods + // --------------------------------------------------------------------------- + describe('log level methods', () => { + const methods = ['verbose', 'debug', 'info', 'warn'] as const; + methods.forEach((method) => { + it(`${method}() calls emit and writes to stdout`, () => { + service[method]('test.event', `${method} message`); + expect(stdoutSpy).toHaveBeenCalledTimes(1); + }); + }); + + it('error() includes error details in log entry', () => { + const err = new Error('disk full'); + service.error('test.error', 'Something broke', err); + const output = stdoutSpy.mock.calls[0][0] as string; + expect(output).toContain('disk full'); + }); + + it('error() handles non-Error objects gracefully', () => { + expect(() => + service.error('test.error', 'oops', 'string error'), + ).not.toThrow(); + }); + }); + + // --------------------------------------------------------------------------- + // Level filtering + // --------------------------------------------------------------------------- + describe('level filtering', () => { + it('suppresses entries below min level', async () => { + const mod = await buildModule({ level: 'warn' }); + const svc = mod.get(SdkDebugService); + svc.debug('test.event', 'should be filtered'); + expect(stdoutSpy).not.toHaveBeenCalled(); + await mod.close(); + }); + + it('allows entries at or above min level', async () => { + const mod = await buildModule({ level: 'warn' }); + const svc = mod.get(SdkDebugService); + svc.error('test.event', 'should appear'); + expect(stdoutSpy).toHaveBeenCalled(); + await mod.close(); + }); + }); + + // --------------------------------------------------------------------------- + // Sensitive key redaction + // --------------------------------------------------------------------------- + describe('sensitive key redaction', () => { + it('redacts configured sensitive keys from meta', () => { + service.debug('test.event', 'msg', { + apiKey: 'super-secret', + safeField: 'visible', + }); + const output = stdoutSpy.mock.calls[0][0] as string; + expect(output).not.toContain('super-secret'); + expect(output).toContain('[REDACTED]'); + }); + + it('keeps non-sensitive keys intact', () => { + service.debug('test.event', 'msg', { userId: 'user-123' }); + const output = stdoutSpy.mock.calls[0][0] as string; + expect(output).toContain('user-123'); + }); + }); + + // --------------------------------------------------------------------------- + // Suppressed events + // --------------------------------------------------------------------------- + describe('suppressedEvents', () => { + it('suppresses matching events', async () => { + const mod = await buildModule({ + suppressedEvents: ['sdk.noisy.event'], + }); + const svc = mod.get(SdkDebugService); + svc.debug('sdk.noisy.event', 'should not appear'); + expect(stdoutSpy).not.toHaveBeenCalled(); + await mod.close(); + }); + }); + + // --------------------------------------------------------------------------- + // Global meta + // --------------------------------------------------------------------------- + describe('globalMeta', () => { + it('attaches global meta to every log entry', () => { + // We test this by checking the stdout output contains our global meta key + // We'll use a custom transport to capture the entry directly + const entries: SdkDebugLogEntry[] = []; + const mod = Test.createTestingModule({ + providers: [ + { + provide: SDK_DEBUG_MODULE_OPTIONS, + useValue: { + enabled: true, + level: 'verbose', + globalMeta: { service: 'BridgeWise', version: '1.0.0' }, + transports: [ + { + type: SDK_DEBUG_TRANSPORT.CUSTOM, + handler: (e: SdkDebugLogEntry) => entries.push(e), + }, + ], + } satisfies SdkDebugModuleOptions, + }, + SdkDebugService, + ], + }).compile(); + + return mod.then(async (m) => { + const svc = m.get(SdkDebugService); + svc.debug('some.event', 'hello'); + expect(entries).toHaveLength(1); + expect(entries[0].meta?.service).toBe('BridgeWise'); + expect(entries[0].meta?.version).toBe('1.0.0'); + await m.close(); + }); + }); + }); + + // --------------------------------------------------------------------------- + // Custom transport + // --------------------------------------------------------------------------- + describe('custom transport', () => { + it('calls the custom handler with the log entry', async () => { + const captured: SdkDebugLogEntry[] = []; + + const mod = await Test.createTestingModule({ + providers: [ + { + provide: SDK_DEBUG_MODULE_OPTIONS, + useValue: { + enabled: true, + level: 'verbose', + transports: [ + { + type: SDK_DEBUG_TRANSPORT.CUSTOM, + handler: (entry: SdkDebugLogEntry) => captured.push(entry), + }, + ], + } satisfies SdkDebugModuleOptions, + }, + SdkDebugService, + ], + }).compile(); + + const svc = mod.get(SdkDebugService); + svc.info('sdk.payment.create', 'Payment initiated', { amount: 100 }); + + expect(captured).toHaveLength(1); + expect(captured[0].event).toBe('sdk.payment.create'); + expect(captured[0].message).toBe('Payment initiated'); + expect(captured[0].meta?.amount).toBe(100); + expect(captured[0].level).toBe('info'); + await mod.close(); + }); + }); + + // --------------------------------------------------------------------------- + // time() helper + // --------------------------------------------------------------------------- + describe('time()', () => { + it('returns a finish function', () => { + const finish = service.time(SDK_DEBUG_EVENTS.REQUEST_START, 'FetchUser'); + expect(typeof finish).toBe('function'); + }); + + it('logs start and end entries', () => { + const finish = service.time(SDK_DEBUG_EVENTS.REQUEST_END, 'FetchUser'); + finish(); + expect(stdoutSpy).toHaveBeenCalledTimes(2); // start + end + }); + }); + + // --------------------------------------------------------------------------- + // trace() helper + // --------------------------------------------------------------------------- + describe('trace()', () => { + it('resolves and logs success', async () => { + const result = await service.trace( + SDK_DEBUG_EVENTS.REQUEST_END, + 'GetBalance', + async () => ({ balance: 500 }), + ); + expect(result).toEqual({ balance: 500 }); + expect(stdoutSpy).toHaveBeenCalledTimes(2); // start + end + }); + + it('rethrows errors and logs failure', async () => { + await expect( + service.trace( + SDK_DEBUG_EVENTS.REQUEST_ERROR, + 'FailingOp', + async () => { + throw new Error('intentional failure'); + }, + ), + ).rejects.toThrow('intentional failure'); + + // start + end + error = 3 writes + expect(stdoutSpy.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + }); + + // --------------------------------------------------------------------------- + // getStats() + // --------------------------------------------------------------------------- + describe('getStats()', () => { + it('tracks total log count', () => { + service.debug('a', 'msg 1'); + service.info('b', 'msg 2'); + service.error('c', 'msg 3'); + const stats = service.getStats(); + expect(stats.totalLogs).toBe(3); + }); + + it('tracks per-level counts', () => { + service.warn('x', 'warn msg'); + const stats = service.getStats(); + expect(stats.logsByLevel.warn).toBeGreaterThanOrEqual(1); + }); + + it('tracks per-event counts', () => { + service.debug(SDK_DEBUG_EVENTS.CACHE_HIT, 'cache hit 1'); + service.debug(SDK_DEBUG_EVENTS.CACHE_HIT, 'cache hit 2'); + const stats = service.getStats(); + expect(stats.logsByEvent[SDK_DEBUG_EVENTS.CACHE_HIT]).toBe(2); + }); + + it('tracks error rate', () => { + service.info('event', 'ok'); + service.info('event', 'ok'); + service.error('event', 'fail'); + const stats = service.getStats(); + // 1 error out of 3 total + expect(stats.errorRate).toBeCloseTo(1 / 3, 2); + }); + + it('returns a copy (immutable snapshot)', () => { + const stats1 = service.getStats(); + service.info('extra.event', 'extra'); + const stats2 = service.getStats(); + expect(stats2.totalLogs).toBeGreaterThan(stats1.totalLogs); + }); + }); + + // --------------------------------------------------------------------------- + // Format variants + // --------------------------------------------------------------------------- + describe('format variants', () => { + it('JSON format outputs valid JSON per line', async () => { + const mod = await buildModule({ format: 'json' }); + const svc = mod.get(SdkDebugService); + svc.info('test.event', 'json format test'); + const output = (stdoutSpy.mock.calls[0][0] as string).trim(); + expect(() => JSON.parse(output)).not.toThrow(); + await mod.close(); + }); + + it('compact format outputs a single line', async () => { + const mod = await buildModule({ format: 'compact' }); + const svc = mod.get(SdkDebugService); + svc.info('test.event', 'compact format test'); + const output = (stdoutSpy.mock.calls[0][0] as string).trim(); + expect(output.includes('\n')).toBe(false); + await mod.close(); + }); + }); + + // --------------------------------------------------------------------------- + // System / memory info + // --------------------------------------------------------------------------- + describe('system and memory info', () => { + it('includes system info when configured', async () => { + const entries: SdkDebugLogEntry[] = []; + const mod = await Test.createTestingModule({ + providers: [ + { + provide: SDK_DEBUG_MODULE_OPTIONS, + useValue: { + enabled: true, + level: 'verbose', + includeSystemInfo: true, + transports: [ + { + type: SDK_DEBUG_TRANSPORT.CUSTOM, + handler: (e: SdkDebugLogEntry) => entries.push(e), + }, + ], + } satisfies SdkDebugModuleOptions, + }, + SdkDebugService, + ], + }).compile(); + + mod.get(SdkDebugService).info('event', 'msg'); + expect(entries[0].system).toBeDefined(); + expect(entries[0].system?.pid).toBe(process.pid); + await mod.close(); + }); + + it('includes memory info when configured', async () => { + const entries: SdkDebugLogEntry[] = []; + const mod = await Test.createTestingModule({ + providers: [ + { + provide: SDK_DEBUG_MODULE_OPTIONS, + useValue: { + enabled: true, + level: 'verbose', + includeMemoryUsage: true, + transports: [ + { + type: SDK_DEBUG_TRANSPORT.CUSTOM, + handler: (e: SdkDebugLogEntry) => entries.push(e), + }, + ], + } satisfies SdkDebugModuleOptions, + }, + SdkDebugService, + ], + }).compile(); + + mod.get(SdkDebugService).info('event', 'msg'); + expect(entries[0].memory).toBeDefined(); + expect(typeof entries[0].memory?.heapUsedMB).toBe('number'); + await mod.close(); + }); + }); +}); diff --git a/apps/api/src/SDK-Debug/sdk-debug.service.ts b/apps/api/src/SDK-Debug/sdk-debug.service.ts new file mode 100644 index 0000000..df939dd --- /dev/null +++ b/apps/api/src/SDK-Debug/sdk-debug.service.ts @@ -0,0 +1,399 @@ +import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; +import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; +import { EventEmitter2 } from '@nestjs/event-emitter'; + +import { + SDK_DEBUG_MODULE_OPTIONS, + SDK_DEBUG_LOG_LEVELS, + SDK_DEBUG_FORMAT, + SDK_DEBUG_TRANSPORT, +} from './sdk-debug.constants'; +import { + SdkDebugLogEntry, + SdkDebugLogLevel, + SdkDebugMemoryInfo, + SdkDebugModuleOptions, + SdkDebugStats, + SdkDebugSystemInfo, +} from './sdk-debug.types'; +import { + colorize, + generateId, + isLevelEnabled, + levelColor, + sanitize, + shouldSample, + toMB, +} from './sdk-debug.utils'; + +@Injectable() +export class SdkDebugService { + private readonly nestLogger = new Logger(SdkDebugService.name); + private readonly systemInfo: SdkDebugSystemInfo; + private readonly stats: SdkDebugStats; + + constructor( + @Inject(SDK_DEBUG_MODULE_OPTIONS) + private readonly options: SdkDebugModuleOptions, + @Optional() private readonly eventEmitter?: EventEmitter2, + ) { + this.systemInfo = { + hostname: os.hostname(), + pid: process.pid, + nodeVersion: process.version, + platform: process.platform, + }; + + this.stats = { + totalLogs: 0, + logsByLevel: { + verbose: 0, + debug: 0, + info: 0, + warn: 0, + error: 0, + }, + logsByEvent: {}, + averageRequestDurationMs: 0, + errorRate: 0, + sampledOut: 0, + }; + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + verbose(event: string, message: string, meta?: Record): void { + this.emit(SDK_DEBUG_LOG_LEVELS.VERBOSE, event, message, meta); + } + + debug(event: string, message: string, meta?: Record): void { + this.emit(SDK_DEBUG_LOG_LEVELS.DEBUG, event, message, meta); + } + + info(event: string, message: string, meta?: Record): void { + this.emit(SDK_DEBUG_LOG_LEVELS.INFO, event, message, meta); + } + + warn(event: string, message: string, meta?: Record): void { + this.emit(SDK_DEBUG_LOG_LEVELS.WARN, event, message, meta); + } + + error( + event: string, + message: string, + error?: Error | unknown, + meta?: Record, + ): void { + const errorInfo = + error instanceof Error + ? { + name: error.name, + message: error.message, + stack: this.options.includeStackTrace ? error.stack : undefined, + code: (error as NodeJS.ErrnoException).code, + } + : error + ? { name: 'UnknownError', message: String(error) } + : undefined; + + this.emit(SDK_DEBUG_LOG_LEVELS.ERROR, event, message, meta, errorInfo); + } + + /** + * Start a timed operation. Returns a finish() function that logs the result. + */ + time( + event: string, + label: string, + meta?: Record, + ): (resultMeta?: Record) => void { + const start = Date.now(); + const traceId = generateId('trace'); + + this.debug(event, `⏱ START ${label}`, { traceId, ...meta }); + + return (resultMeta?: Record) => { + const duration = Date.now() - start; + this.updateAverageRequestDuration(duration); + this.debug(event, `⏱ END ${label} [${duration}ms]`, { + traceId, + duration, + ...resultMeta, + }); + }; + } + + /** + * Wrap an async fn with automatic start/end/error debug logs. + */ + async trace( + event: string, + label: string, + fn: () => Promise, + meta?: Record, + ): Promise { + const finish = this.time(event, label, meta); + try { + const result = await fn(); + finish({ success: true }); + return result; + } catch (err) { + finish({ success: false }); + this.error(event, `TRACE ERROR: ${label}`, err, meta); + throw err; + } + } + + /** Retrieve runtime stats */ + getStats(): Readonly { + return { ...this.stats }; + } + + /** Check if debug is active */ + get isEnabled(): boolean { + return this.options.enabled; + } + + // --------------------------------------------------------------------------- + // Internal + // --------------------------------------------------------------------------- + + private emit( + level: SdkDebugLogLevel, + event: string, + message: string, + meta?: Record, + errorInfo?: SdkDebugLogEntry['error'], + ): void { + if (!this.options.enabled) return; + + const minLevel = this.options.level ?? SDK_DEBUG_LOG_LEVELS.DEBUG; + if (!isLevelEnabled(level, minLevel)) return; + + const suppressedEvents = this.options.suppressedEvents ?? []; + if (suppressedEvents.includes(event)) return; + + const samplingRate = this.options.samplingRate ?? 1; + if (!shouldSample(samplingRate)) { + this.stats.sampledOut++; + return; + } + + const entry = this.buildEntry(level, event, message, meta, errorInfo); + + this.updateStats(entry); + this.dispatch(entry); + } + + private buildEntry( + level: SdkDebugLogLevel, + event: string, + message: string, + meta?: Record, + errorInfo?: SdkDebugLogEntry['error'], + ): SdkDebugLogEntry { + const sensitiveKeys = this.options.sensitiveKeys ?? []; + const maxDepth = this.options.maxDepth ?? 8; + const maxStringLength = this.options.maxStringLength ?? 2048; + + const mergedMeta = { + ...this.options.globalMeta, + ...meta, + }; + + const sanitizedMeta = sanitize( + mergedMeta, + sensitiveKeys, + maxDepth, + maxStringLength, + ) as Record; + + const entry: SdkDebugLogEntry = { + timestamp: new Date().toISOString(), + level, + namespace: this.options.namespace, + event, + message, + meta: Object.keys(sanitizedMeta).length ? sanitizedMeta : undefined, + error: errorInfo, + }; + + if (this.options.includeSystemInfo) { + entry.system = this.systemInfo; + } + + if (this.options.includeMemoryUsage) { + entry.memory = this.captureMemory(); + } + + return entry; + } + + private dispatch(entry: SdkDebugLogEntry): void { + const transports = this.options.transports ?? [ + { type: SDK_DEBUG_TRANSPORT.CONSOLE }, + ]; + + for (const transport of transports) { + try { + switch (transport.type) { + case SDK_DEBUG_TRANSPORT.CONSOLE: + this.writeConsole(entry); + break; + + case SDK_DEBUG_TRANSPORT.FILE: + if (transport.filePath) this.writeFile(entry, transport.filePath); + break; + + case SDK_DEBUG_TRANSPORT.HTTP: + if (transport.endpoint) + this.writeHttp(entry, transport.endpoint, transport.headers); + break; + + case SDK_DEBUG_TRANSPORT.CUSTOM: + if (transport.handler) { + Promise.resolve(transport.handler(entry)).catch((err) => + this.nestLogger.error('Custom transport error', err), + ); + } + break; + } + } catch (err) { + this.nestLogger.error(`Transport ${transport.type} failed`, err); + } + } + + if (this.options.emitEvents && this.eventEmitter) { + this.eventEmitter.emit(entry.event, entry); + } + } + + // --------------------------------------------------------------------------- + // Transport implementations + // --------------------------------------------------------------------------- + + private writeConsole(entry: SdkDebugLogEntry): void { + const format = this.options.format ?? SDK_DEBUG_FORMAT.PRETTY; + const useColor = this.options.colorize !== false; + + if (format === SDK_DEBUG_FORMAT.JSON) { + process.stdout.write(JSON.stringify(entry) + '\n'); + return; + } + + if (format === SDK_DEBUG_FORMAT.COMPACT) { + const ns = entry.namespace ? `[${entry.namespace}] ` : ''; + const dur = entry.duration ? ` (${entry.duration}ms)` : ''; + process.stdout.write( + `${entry.timestamp} ${entry.level.toUpperCase()} ${ns}${entry.event}: ${entry.message}${dur}\n`, + ); + return; + } + + // Pretty format + const ts = useColor + ? colorize(entry.timestamp, 'gray') + : entry.timestamp; + const lvl = useColor + ? colorize(entry.level.toUpperCase().padEnd(7), levelColor(entry.level), 'bold') + : entry.level.toUpperCase().padEnd(7); + const ns = entry.namespace + ? useColor + ? colorize(`[${entry.namespace}]`, 'magenta') + : `[${entry.namespace}]` + : ''; + const evt = useColor + ? colorize(entry.event, 'cyan') + : entry.event; + const msg = useColor + ? colorize(entry.message, 'white', 'bold') + : entry.message; + + let line = `${ts} ${lvl} ${ns} ${evt} → ${msg}`; + + if (entry.meta && Object.keys(entry.meta).length) { + const metaStr = this.options.prettyPrint + ? '\n' + JSON.stringify(entry.meta, null, 2) + : ' ' + JSON.stringify(entry.meta); + line += useColor ? colorize(metaStr, 'dim') : metaStr; + } + + if (entry.error) { + const errStr = `\n ERROR: ${entry.error.name}: ${entry.error.message}`; + line += useColor ? colorize(errStr, 'red') : errStr; + if (entry.error.stack) { + const stackStr = '\n' + entry.error.stack; + line += useColor ? colorize(stackStr, 'gray') : stackStr; + } + } + + if (entry.memory) { + line += useColor + ? colorize( + ` [heap: ${entry.memory.heapUsedMB}/${entry.memory.heapTotalMB} MB]`, + 'dim', + ) + : ` [heap: ${entry.memory.heapUsedMB}/${entry.memory.heapTotalMB} MB]`; + } + + process.stdout.write(line + '\n'); + } + + private writeFile(entry: SdkDebugLogEntry, filePath: string): void { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.appendFileSync(filePath, JSON.stringify(entry) + '\n', 'utf8'); + } + + private writeHttp( + entry: SdkDebugLogEntry, + endpoint: string, + headers?: Record, + ): void { + // Fire-and-forget — non-blocking + fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(entry), + }).catch((err) => + this.nestLogger.warn(`HTTP transport failed: ${err.message}`), + ); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private captureMemory(): SdkDebugMemoryInfo { + const m = process.memoryUsage(); + return { + heapUsedMB: toMB(m.heapUsed), + heapTotalMB: toMB(m.heapTotal), + externalMB: toMB(m.external), + rssMB: toMB(m.rss), + }; + } + + private updateStats(entry: SdkDebugLogEntry): void { + this.stats.totalLogs++; + this.stats.logsByLevel[entry.level]++; + this.stats.logsByEvent[entry.event] = + (this.stats.logsByEvent[entry.event] ?? 0) + 1; + + if (entry.level === SDK_DEBUG_LOG_LEVELS.ERROR) { + this.stats.errorRate = + this.stats.logsByLevel.error / this.stats.totalLogs; + } + } + + private updateAverageRequestDuration(duration: number): void { + const prev = this.stats.averageRequestDurationMs; + const count = this.stats.logsByEvent['sdk.request.end'] ?? 1; + this.stats.averageRequestDurationMs = + (prev * (count - 1) + duration) / count; + } +} diff --git a/apps/api/src/SDK-Debug/sdk-debug.utils.spec.ts b/apps/api/src/SDK-Debug/sdk-debug.utils.spec.ts new file mode 100644 index 0000000..449d6d0 --- /dev/null +++ b/apps/api/src/SDK-Debug/sdk-debug.utils.spec.ts @@ -0,0 +1,179 @@ +import { + colorize, + generateId, + isLevelEnabled, + levelColor, + sanitize, + shouldSample, + toMB, +} from '../src/sdk-debug.utils'; +import { REDACTED_PLACEHOLDER } from '../src/sdk-debug.constants'; + +describe('SdkDebugUtils', () => { + // --------------------------------------------------------------------------- + // sanitize() + // --------------------------------------------------------------------------- + describe('sanitize()', () => { + it('returns primitives unchanged', () => { + expect(sanitize(42)).toBe(42); + expect(sanitize(true)).toBe(true); + expect(sanitize(null)).toBeNull(); + expect(sanitize(undefined)).toBeUndefined(); + }); + + it('truncates long strings', () => { + const long = 'x'.repeat(3000); + const result = sanitize(long, [], 8, 2048) as string; + expect(result.length).toBeLessThan(3000); + expect(result).toContain('[TRUNCATED]'); + }); + + it('does not truncate short strings', () => { + const short = 'hello world'; + expect(sanitize(short, [], 8, 2048)).toBe(short); + }); + + it('redacts top-level sensitive key', () => { + const obj = { password: 'supersecret', name: 'Alice' }; + const result = sanitize(obj, ['password']) as Record; + expect(result.password).toBe(REDACTED_PLACEHOLDER); + expect(result.name).toBe('Alice'); + }); + + it('redacts nested sensitive keys', () => { + const obj = { user: { token: 'abc123', id: 1 } }; + const result = sanitize(obj, ['token']) as { + user: Record; + }; + expect(result.user.token).toBe(REDACTED_PLACEHOLDER); + expect(result.user.id).toBe(1); + }); + + it('redacts case-insensitively', () => { + const obj = { ApiKey: 'key-value' }; + const result = sanitize(obj, ['apikey']) as Record; + expect(result.ApiKey).toBe(REDACTED_PLACEHOLDER); + }); + + it('handles arrays recursively', () => { + const arr = [{ secret: 'shh' }, { value: 1 }]; + const result = sanitize(arr, ['secret']) as Array>; + expect(result[0].secret).toBe(REDACTED_PLACEHOLDER); + expect(result[1].value).toBe(1); + }); + + it('stops at maxDepth and returns a placeholder', () => { + // Build a deeply nested object + let deep: Record = { leaf: 'value' }; + for (let i = 0; i < 10; i++) deep = { child: deep }; + const result = sanitize(deep, [], 3) as Record; + // At depth 3 we should see the max depth placeholder somewhere + const str = JSON.stringify(result); + expect(str).toContain('MAX_DEPTH_REACHED'); + }); + + it('serialises Error instances', () => { + const err = new Error('boom'); + const result = sanitize(err) as Record; + expect(result.name).toBe('Error'); + expect(result.message).toBe('boom'); + }); + }); + + // --------------------------------------------------------------------------- + // generateId() + // --------------------------------------------------------------------------- + describe('generateId()', () => { + it('returns a non-empty string', () => { + const id = generateId(); + expect(typeof id).toBe('string'); + expect(id.length).toBeGreaterThan(0); + }); + + it('includes the prefix when provided', () => { + const id = generateId('trace'); + expect(id.startsWith('trace-')).toBe(true); + }); + + it('generates unique ids', () => { + const ids = new Set(Array.from({ length: 100 }, () => generateId())); + expect(ids.size).toBe(100); + }); + }); + + // --------------------------------------------------------------------------- + // toMB() + // --------------------------------------------------------------------------- + describe('toMB()', () => { + it('converts bytes to MB with 2 decimal precision', () => { + expect(toMB(1048576)).toBe(1); // 1 MB exactly + expect(toMB(1572864)).toBe(1.5); // 1.5 MB + expect(toMB(0)).toBe(0); + }); + }); + + // --------------------------------------------------------------------------- + // shouldSample() + // --------------------------------------------------------------------------- + describe('shouldSample()', () => { + it('always returns true for rate = 1', () => { + for (let i = 0; i < 50; i++) { + expect(shouldSample(1)).toBe(true); + } + }); + + it('always returns false for rate = 0', () => { + for (let i = 0; i < 50; i++) { + expect(shouldSample(0)).toBe(false); + } + }); + + it('returns roughly correct ratio for rate = 0.5', () => { + const results = Array.from({ length: 1000 }, () => shouldSample(0.5)); + const trueCount = results.filter(Boolean).length; + // Allow ±15% variance + expect(trueCount).toBeGreaterThan(350); + expect(trueCount).toBeLessThan(650); + }); + }); + + // --------------------------------------------------------------------------- + // isLevelEnabled() + // --------------------------------------------------------------------------- + describe('isLevelEnabled()', () => { + it('returns true when entry level >= min level', () => { + expect(isLevelEnabled('error', 'debug')).toBe(true); + expect(isLevelEnabled('warn', 'warn')).toBe(true); + expect(isLevelEnabled('info', 'info')).toBe(true); + }); + + it('returns false when entry level < min level', () => { + expect(isLevelEnabled('debug', 'warn')).toBe(false); + expect(isLevelEnabled('verbose', 'info')).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // colorize() / levelColor() + // --------------------------------------------------------------------------- + describe('colorize() and levelColor()', () => { + it('wraps string with ANSI codes', () => { + const result = colorize('hello', 'red'); + expect(result).toContain('hello'); + expect(result).toContain('\x1b[31m'); // red code + }); + + it('returns a known level color key', () => { + const color = levelColor('error'); + expect(color).toBe('red'); + expect(levelColor('warn')).toBe('yellow'); + expect(levelColor('info')).toBe('green'); + expect(levelColor('debug')).toBe('cyan'); + expect(levelColor('verbose')).toBe('gray'); + }); + + it('returns white for unknown level', () => { + expect(levelColor('unknown-level')).toBe('white'); + }); + }); +}); diff --git a/apps/api/src/SDK-Debug/sdk-debug.utils.ts b/apps/api/src/SDK-Debug/sdk-debug.utils.ts new file mode 100644 index 0000000..02965d2 --- /dev/null +++ b/apps/api/src/SDK-Debug/sdk-debug.utils.ts @@ -0,0 +1,128 @@ +import { DEFAULT_SENSITIVE_KEYS, REDACTED_PLACEHOLDER } from './sdk-debug.constants'; + +/** + * Deeply traverses an object and replaces values whose keys match + * the sensitive-key list with REDACTED_PLACEHOLDER. + */ +export function sanitize( + value: unknown, + sensitiveKeys: string[] = DEFAULT_SENSITIVE_KEYS, + maxDepth = 8, + maxStringLength = 2048, + _depth = 0, +): unknown { + if (_depth > maxDepth) return '[MAX_DEPTH_REACHED]'; + + if (value === null || value === undefined) return value; + + if (typeof value === 'string') { + return value.length > maxStringLength + ? value.slice(0, maxStringLength) + '…[TRUNCATED]' + : value; + } + + if (typeof value === 'number' || typeof value === 'boolean') return value; + + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack, + }; + } + + if (Array.isArray(value)) { + return value.map((item) => + sanitize(item, sensitiveKeys, maxDepth, maxStringLength, _depth + 1), + ); + } + + if (typeof value === 'object') { + const result: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + const isSensitive = sensitiveKeys.some( + (sk) => k.toLowerCase() === sk.toLowerCase(), + ); + result[k] = isSensitive + ? REDACTED_PLACEHOLDER + : sanitize(v, sensitiveKeys, maxDepth, maxStringLength, _depth + 1); + } + return result; + } + + return value; +} + +/** + * Generates a lightweight random trace/request ID (no external deps). + */ +export function generateId(prefix = ''): string { + const ts = Date.now().toString(36); + const rand = Math.random().toString(36).slice(2, 9); + return prefix ? `${prefix}-${ts}-${rand}` : `${ts}-${rand}`; +} + +/** + * Formats bytes to a two-decimal MB string. + */ +export function toMB(bytes: number): number { + return Math.round((bytes / 1024 / 1024) * 100) / 100; +} + +/** + * Colorize a string for terminal output. + */ +const COLORS: Record = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + red: '\x1b[31m', + yellow: '\x1b[33m', + green: '\x1b[32m', + cyan: '\x1b[36m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + white: '\x1b[37m', + gray: '\x1b[90m', +}; + +export function colorize(text: string, ...styles: string[]): string { + const codes = styles.map((s) => COLORS[s] ?? '').join(''); + return `${codes}${text}${COLORS.reset}`; +} + +const LEVEL_COLORS: Record = { + verbose: 'gray', + debug: 'cyan', + info: 'green', + warn: 'yellow', + error: 'red', +}; + +export function levelColor(level: string): string { + return LEVEL_COLORS[level] ?? 'white'; +} + +/** + * Determines whether a log entry should be sampled in (true = emit it). + */ +export function shouldSample(rate: number): boolean { + if (rate >= 1) return true; + if (rate <= 0) return false; + return Math.random() < rate; +} + +const LEVEL_WEIGHT: Record = { + verbose: 0, + debug: 1, + info: 2, + warn: 3, + error: 4, +}; + +export function isLevelEnabled( + entryLevel: string, + minLevel: string, +): boolean { + return (LEVEL_WEIGHT[entryLevel] ?? 0) >= (LEVEL_WEIGHT[minLevel] ?? 0); +} diff --git a/apps/api/src/SDK-Debug/tsconfig.json b/apps/api/src/SDK-Debug/tsconfig.json new file mode 100644 index 0000000..2155964 --- /dev/null +++ b/apps/api/src/SDK-Debug/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "dist"] +}