refactor!: simplify runtime coordination #7
Keep financial ownership, atomic publication and refresh CAS. Failed refresh leaves cookies untouched; logout still clears them. Remove full-history candle prefetch and special metadata retries. Use native Pino request context instead of parallel middleware. BREAKING CHANGE: remove investment stateVersion, queued sync state, and tbank-sync status progress/resources. Consumers must observe resources through sync=observe with the updated frontend contract.
This commit is contained in:
@@ -1,16 +1,66 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Options } from 'pino-http';
|
||||
|
||||
import { RequestContextService } from './request-context.service';
|
||||
import { RequestLoggingMiddleware } from './request-logging.middleware';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { constants as httpStatus } from 'node:http2';
|
||||
|
||||
import { Environment } from '../../config/env.validation';
|
||||
import { StructuredLogger } from './structured-logger.service';
|
||||
|
||||
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function completion(request: Request, response: Response, value: { responseTime: number }) {
|
||||
const route: unknown = request.route;
|
||||
const path = typeof route === 'object' && route !== null && 'path' in route &&
|
||||
typeof route.path === 'string'
|
||||
? `${request.baseUrl}${route.path}`
|
||||
: '<unmatched>';
|
||||
return {
|
||||
method: request.method,
|
||||
route: path,
|
||||
status: response.statusCode,
|
||||
durationMs: Math.round(value.responseTime),
|
||||
...(typeof response.locals.publicErrorCode === 'string' ? { code: response.locals.publicErrorCode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function loggingOptions(config: ConfigService<Environment, true>): Options<Request, Response> {
|
||||
return {
|
||||
level: config.getOrThrow('LOG_LEVEL'),
|
||||
messageKey: 'message',
|
||||
genReqId: (_request, response) => {
|
||||
const id = randomUUID();
|
||||
response.setHeader('X-Request-ID', id);
|
||||
return id;
|
||||
},
|
||||
customProps: (request) => {
|
||||
const incoming = request.header('x-request-id');
|
||||
return { requestId: request.id, ...(incoming && uuid.test(incoming) ? { clientRequestId: incoming } : {}) };
|
||||
},
|
||||
customLogLevel: (_request, response) => {
|
||||
if (response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR) return 'error';
|
||||
if ([httpStatus.HTTP_STATUS_FORBIDDEN, httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS, httpStatus.HTTP_STATUS_UNAUTHORIZED].includes(response.statusCode)) return 'warn';
|
||||
return 'info';
|
||||
},
|
||||
customSuccessMessage: () => 'http.request.completed',
|
||||
customErrorMessage: () => 'http.request.completed',
|
||||
customSuccessObject: completion,
|
||||
customErrorObject: (request, response, _error, value: { responseTime: number }) => completion(request, response, value),
|
||||
redact: ['req.headers.authorization', 'req.headers.cookie', 'req.body'],
|
||||
serializers: { req: () => undefined },
|
||||
};
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
RequestContextService,
|
||||
RequestLoggingMiddleware,
|
||||
StructuredLogger,
|
||||
],
|
||||
exports: [RequestContextService, RequestLoggingMiddleware, StructuredLogger],
|
||||
imports: [LoggerModule.forRootAsync<Request, Response>({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService<Environment, true>) => ({ pinoHttp: loggingOptions(config), assignResponse: true }),
|
||||
})],
|
||||
providers: [StructuredLogger],
|
||||
exports: [StructuredLogger],
|
||||
})
|
||||
export class LoggingModule {}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
export type RequestLogContext = {
|
||||
brokerageAccountId?: string;
|
||||
clientRequestId?: string;
|
||||
requestId: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RequestContextService {
|
||||
private readonly storage = new AsyncLocalStorage<RequestLogContext>();
|
||||
|
||||
run<T>(context: RequestLogContext, callback: () => T): T {
|
||||
return this.storage.run(context, callback);
|
||||
}
|
||||
|
||||
get(): RequestLogContext | undefined {
|
||||
return this.storage.getStore();
|
||||
}
|
||||
|
||||
set(context: Partial<RequestLogContext>): void {
|
||||
const current = this.storage.getStore();
|
||||
if (current) Object.assign(current, context);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
import { RequestLoggingMiddleware } from './request-logging.middleware';
|
||||
|
||||
describe('RequestLoggingMiddleware', () => {
|
||||
it('returns a server ID and emits one safe completion event', () => {
|
||||
const requestContext = {
|
||||
run: jest.fn((_: unknown, next: () => void) => next()),
|
||||
};
|
||||
const logger = { event: jest.fn() };
|
||||
const middleware = new RequestLoggingMiddleware(
|
||||
requestContext as never,
|
||||
logger as never,
|
||||
);
|
||||
const response = Object.assign(new EventEmitter(), {
|
||||
statusCode: 200,
|
||||
locals: {},
|
||||
setHeader: jest.fn(),
|
||||
});
|
||||
const next = jest.fn();
|
||||
|
||||
const request: {
|
||||
baseUrl: string;
|
||||
header: jest.Mock<string>;
|
||||
id?: string;
|
||||
method: string;
|
||||
route: { path: string };
|
||||
} = {
|
||||
method: 'GET',
|
||||
baseUrl: '/api/v1',
|
||||
route: { path: '/accounts/:id' },
|
||||
header: jest.fn(() => 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43'),
|
||||
};
|
||||
middleware.use(request as never, response as never, next);
|
||||
response.emit('finish');
|
||||
|
||||
expect(response.setHeader).toHaveBeenCalledWith(
|
||||
'X-Request-ID',
|
||||
expect.stringMatching(/^[0-9a-f-]{36}$/i),
|
||||
);
|
||||
expect(request.id).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
expect(requestContext.run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
clientRequestId: 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43',
|
||||
}),
|
||||
next,
|
||||
);
|
||||
expect(logger.event).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'http.request.completed',
|
||||
expect.objectContaining({ route: '/api/v1/accounts/:id', status: 200 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { constants as httpStatus } from 'node:http2';
|
||||
|
||||
import { RequestContextService } from './request-context.service';
|
||||
import { StructuredLogger } from './structured-logger.service';
|
||||
|
||||
const uuid =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
@Injectable()
|
||||
export class RequestLoggingMiddleware implements NestMiddleware {
|
||||
constructor(
|
||||
private readonly requestContext: RequestContextService,
|
||||
private readonly logger: StructuredLogger,
|
||||
) {}
|
||||
|
||||
use(request: Request, response: Response, next: NextFunction): void {
|
||||
const incoming = request.header('x-request-id');
|
||||
const clientRequestId =
|
||||
incoming && uuid.test(incoming) ? incoming : undefined;
|
||||
const existingRequestId = (request as Request & { id?: unknown }).id;
|
||||
const requestId =
|
||||
typeof existingRequestId === 'string' ? existingRequestId : randomUUID();
|
||||
(request as unknown as { id: string }).id = requestId;
|
||||
const startedAt = performance.now();
|
||||
|
||||
response.setHeader('X-Request-ID', requestId);
|
||||
response.on('finish', () => {
|
||||
const routeRequest: unknown = request;
|
||||
const routePath = (routeRequest as { route?: { path?: unknown } }).route
|
||||
?.path;
|
||||
const route =
|
||||
typeof routePath === 'string'
|
||||
? `${request.baseUrl}${routePath}`
|
||||
: '<unmatched>';
|
||||
const context = {
|
||||
method: request.method,
|
||||
route,
|
||||
status: response.statusCode,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
...(response.locals.publicErrorCode
|
||||
? { code: response.locals.publicErrorCode as string }
|
||||
: {}),
|
||||
};
|
||||
const level =
|
||||
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR
|
||||
? 'error'
|
||||
: response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
|
||||
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
|
||||
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN
|
||||
? 'warn'
|
||||
: 'info';
|
||||
this.logger.event(level, 'http.request.completed', context);
|
||||
});
|
||||
|
||||
this.requestContext.run(
|
||||
{
|
||||
requestId,
|
||||
...(clientRequestId ? { clientRequestId } : {}),
|
||||
},
|
||||
next,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { StructuredLogger } from './structured-logger.service';
|
||||
|
||||
describe('StructuredLogger', () => {
|
||||
const context = { get: jest.fn() };
|
||||
const pino = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
@@ -12,8 +11,7 @@ describe('StructuredLogger', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('passes only allowlisted fields to Pino', () => {
|
||||
context.get.mockReturnValue({ requestId: 'request', token: 'secret' });
|
||||
const logger = new StructuredLogger(context as never, pino as never);
|
||||
const logger = new StructuredLogger(pino as never);
|
||||
|
||||
logger.info('event', {
|
||||
userId: 'user',
|
||||
@@ -23,7 +21,6 @@ describe('StructuredLogger', () => {
|
||||
|
||||
expect(pino.info).toHaveBeenCalledWith(
|
||||
{
|
||||
requestId: 'request',
|
||||
userId: 'user',
|
||||
brokerageAccountId: 'account',
|
||||
},
|
||||
@@ -32,8 +29,7 @@ describe('StructuredLogger', () => {
|
||||
});
|
||||
|
||||
it('delegates event levels to Pino', () => {
|
||||
context.get.mockReturnValue(undefined);
|
||||
const logger = new StructuredLogger(context as never, pino as never);
|
||||
const logger = new StructuredLogger(pino as never);
|
||||
|
||||
logger.event('debug', 'debug event');
|
||||
logger.event('warn', 'warn event');
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { RequestContextService } from './request-context.service';
|
||||
|
||||
type LogLevel = 'debug' | 'error' | 'info' | 'warn';
|
||||
type LogContext = Record<string, boolean | number | string | undefined>;
|
||||
|
||||
@@ -27,10 +25,7 @@ const allowedFields = new Set([
|
||||
|
||||
@Injectable()
|
||||
export class StructuredLogger implements OnApplicationShutdown {
|
||||
constructor(
|
||||
private readonly requestContext: RequestContextService,
|
||||
private readonly pino: PinoLogger,
|
||||
) {}
|
||||
constructor(private readonly pino: PinoLogger) {}
|
||||
|
||||
info(message: string, context?: LogContext): void {
|
||||
this.write('info', message, context);
|
||||
@@ -51,7 +46,7 @@ export class StructuredLogger implements OnApplicationShutdown {
|
||||
): void {
|
||||
// eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union.
|
||||
this.pino[level](
|
||||
this.safeContext({ ...this.requestContext.get(), ...context }),
|
||||
this.safeContext(context),
|
||||
message,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user