feat: add logging

This commit is contained in:
2026-08-14 17:12:15 +03:00
parent 34c2b79e27
commit 25d7c5780b
25 changed files with 753 additions and 108 deletions
+38 -2
View File
@@ -1,7 +1,10 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { randomUUID } from 'node:crypto';
import { LoggerModule } from 'nestjs-pino';
import { AuthModule } from './auth/auth.module';
import { validateEnv } from './config/env.validation';
import { LoggingModule } from './common/logging/logging.module';
import { Environment, validateEnv } from './config/env.validation';
import { PrismaModule } from './prisma/prisma.module';
import { TbankConnectionModule } from './tbank/tbank-connection.module';
@@ -11,6 +14,39 @@ import { TbankConnectionModule } from './tbank/tbank-connection.module';
isGlobal: true,
validate: validateEnv,
}),
LoggerModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService<Environment, true>) => ({
pinoHttp: {
level: config.getOrThrow('LOG_LEVEL'),
messageKey: 'message',
autoLogging: false,
genReqId: (request: unknown, response) => {
const existingId = (request as { id?: unknown }).id;
const requestId =
typeof existingId === 'string' ? existingId : randomUUID();
response.setHeader('X-Request-ID', requestId);
return requestId;
},
redact: [
'req.headers.authorization',
'req.headers.cookie',
'req.body',
],
serializers: {
req: (request: unknown) => {
const value = request as { id?: unknown; method?: unknown };
return {
id: typeof value.id === 'string' ? value.id : undefined,
method:
typeof value.method === 'string' ? value.method : undefined,
};
},
},
},
}),
}),
LoggingModule,
PrismaModule,
AuthModule,
TbankConnectionModule,
+18 -2
View File
@@ -1,5 +1,21 @@
import { Injectable } from '@nestjs/common';
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { RequestContextService } from '../common/logging/request-context.service';
import { AccessPayload } from './auth.types';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly requestContext: RequestContextService) {
super();
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const allowed = await super.canActivate(context);
const request = context
.switchToHttp()
.getRequest<{ user?: AccessPayload }>();
if (request.user?.sub)
this.requestContext.set({ userId: request.user.sub });
return Boolean(allowed);
}
}
@@ -6,10 +6,11 @@ describe('HttpExceptionFilter', () => {
const status = jest.fn().mockReturnThis();
const type = jest.fn().mockReturnThis();
const json = jest.fn();
const locals: Record<string, unknown> = {};
const filter = new HttpExceptionFilter();
const host = {
switchToHttp: () => ({
getResponse: () => ({ status, type, json }),
getResponse: () => ({ status, type, json, locals }),
}),
} as unknown as ArgumentsHost;
@@ -21,5 +22,8 @@ describe('HttpExceptionFilter', () => {
status: 500,
code: 'INTERNAL_SERVER_ERROR',
});
expect(locals.publicErrorCode).toBe('INTERNAL_SERVER_ERROR');
expect(typeof locals.errorStack).toBe('string');
expect(locals.errorStack).not.toContain('database password: secret');
});
});
+13 -1
View File
@@ -23,12 +23,24 @@ export class HttpExceptionFilter implements ExceptionFilter {
const isValidation = Array.isArray(
(payload as { message?: unknown })?.message,
);
const code = isValidation
? 'VALIDATION_ERROR'
: this.codeFor(status, payload);
(response.locals ??= {}).publicErrorCode = code;
if (
status >= Number(HttpStatus.INTERNAL_SERVER_ERROR) &&
exception instanceof Error
)
response.locals.errorStack = exception.stack
?.split('\n')
.slice(1)
.join('\n');
response
.status(status)
.type('application/problem+json')
.json({
status,
code: isValidation ? 'VALIDATION_ERROR' : this.codeFor(status, payload),
code,
...(isValidation
? {
errors: this.validationErrors(
+15
View File
@@ -0,0 +1,15 @@
import { Global, Module } from '@nestjs/common';
import { RequestContextService } from './request-context.service';
import { RequestLoggingMiddleware } from './request-logging.middleware';
import { StructuredLogger } from './structured-logger.service';
@Global()
@Module({
providers: [
RequestContextService,
RequestLoggingMiddleware,
StructuredLogger,
],
exports: [RequestContextService, RequestLoggingMiddleware, StructuredLogger],
})
export class LoggingModule {}
@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
export type RequestLogContext = {
requestId: string;
clientRequestId?: string;
userId?: string;
brokerageAccountId?: 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);
}
}
@@ -0,0 +1,53 @@
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: {
method: string;
baseUrl: string;
route: { path: string };
header: jest.Mock<string>;
id?: 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 }),
);
});
});
@@ -0,0 +1,67 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
import { randomUUID } from 'node:crypto';
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 }
: {}),
...(response.locals.errorStack
? { stack: response.locals.errorStack as string }
: {}),
};
const level =
response.statusCode >= 500
? 'error'
: response.statusCode === 429 ||
response.statusCode === 401 ||
response.statusCode === 403
? 'warn'
: 'info';
this.logger.event(level, 'http.request.completed', context);
});
this.requestContext.run(
{
requestId,
...(clientRequestId ? { clientRequestId } : {}),
},
next,
);
}
}
@@ -0,0 +1,44 @@
import { StructuredLogger } from './structured-logger.service';
describe('StructuredLogger', () => {
const context = { get: jest.fn() };
const pino = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
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);
logger.info('event', {
userId: 'user',
brokerageAccountId: 'account',
password: 'secret',
});
expect(pino.info).toHaveBeenCalledWith(
{
requestId: 'request',
userId: 'user',
brokerageAccountId: 'account',
},
'event',
);
});
it('delegates event levels to Pino', () => {
context.get.mockReturnValue(undefined);
const logger = new StructuredLogger(context as never, pino as never);
logger.event('debug', 'debug event');
logger.event('warn', 'warn event');
expect(pino.debug).toHaveBeenCalledWith({}, 'debug event');
expect(pino.warn).toHaveBeenCalledWith({}, 'warn event');
});
});
@@ -0,0 +1,65 @@
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino';
import { RequestContextService } from './request-context.service';
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
type LogContext = Record<string, string | number | boolean | undefined>;
const allowedFields = new Set([
'service',
'environment',
'requestId',
'clientRequestId',
'userId',
'brokerageAccountId',
'method',
'route',
'status',
'durationMs',
'code',
'stack',
'operation',
'grpcCode',
'attempts',
'count',
'found',
]);
@Injectable()
export class StructuredLogger implements OnApplicationShutdown {
constructor(
private readonly requestContext: RequestContextService,
private readonly pino: PinoLogger,
) {}
info(message: string, context?: LogContext): void {
this.write('info', message, context);
}
event(level: LogLevel, message: string, context?: LogContext): void {
this.write(level, message, context);
}
onApplicationShutdown(): void {
this.info('application.stopped');
}
private write(
level: LogLevel,
message: string,
context: LogContext = {},
): void {
this.pino[level](
this.safeContext({ ...this.requestContext.get(), ...context }),
message,
);
}
private safeContext(context: LogContext): LogContext {
return Object.fromEntries(
Object.entries(context).filter(
([key, value]) => allowedFields.has(key) && value !== undefined,
),
);
}
}
+13
View File
@@ -17,9 +17,22 @@ describe('validateEnv', () => {
TOKEN_ENCRYPTION_KEY: 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=',
TBANK_GRPC_ENDPOINT: 'invest-public-api.tbank.ru:443',
TBANK_RATE_LIMIT_PER_SECOND: 5,
LOG_LEVEL: 'info',
});
});
it('rejects an unknown log level', () => {
expect(() =>
validateEnv({
DATABASE_URL: 'file:./data.db',
FRONTEND_ORIGIN: 'http://localhost:5173',
JWT_SECRET: 'a-secret-that-is-at-least-thirty-two-characters',
TOKEN_ENCRYPTION_KEY: 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=',
LOG_LEVEL: 'trace',
}),
).toThrow('LOG_LEVEL must be debug, info, warn, or error');
});
it('fails fast when required configuration is missing', () => {
expect(() =>
validateEnv({ FRONTEND_ORIGIN: 'http://localhost:5173' }),
+7
View File
@@ -7,6 +7,7 @@ export interface Environment {
TBANK_CA_CERT_PATH?: string;
TBANK_GRPC_ENDPOINT: string;
TBANK_RATE_LIMIT_PER_SECOND: number;
LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error';
}
export function validateEnv(config: Record<string, unknown>): Environment {
@@ -19,6 +20,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
config['TBANK_GRPC_ENDPOINT'] ?? 'invest-public-api.tbank.ru:443';
const tbankRateLimit = config['TBANK_RATE_LIMIT_PER_SECOND'] ?? 5;
const tbankCaCertPath = config['TBANK_CA_CERT_PATH'];
const logLevel = config['LOG_LEVEL'] ?? 'info';
if (typeof databaseUrl !== 'string' || !databaseUrl.startsWith('file:')) {
throw new Error('DATABASE_URL must be a SQLite file URL');
@@ -76,6 +78,10 @@ export function validateEnv(config: Record<string, unknown>): Environment {
throw new Error('TBANK_CA_CERT_PATH must be a file path');
}
if (!['debug', 'info', 'warn', 'error'].includes(logLevel as string)) {
throw new Error('LOG_LEVEL must be debug, info, warn, or error');
}
return {
DATABASE_URL: databaseUrl,
FRONTEND_ORIGIN: parsedOrigin.origin,
@@ -87,5 +93,6 @@ export function validateEnv(config: Record<string, unknown>): Environment {
: {}),
TBANK_GRPC_ENDPOINT: tbankEndpoint,
TBANK_RATE_LIMIT_PER_SECOND: parsedTbankRateLimit,
LOG_LEVEL: logLevel as Environment['LOG_LEVEL'],
};
}
+25 -5
View File
@@ -1,23 +1,39 @@
import { ValidationPipe } from '@nestjs/common';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { RequestLoggingMiddleware } from './common/logging/request-logging.middleware';
import { StructuredLogger } from './common/logging/structured-logger.service';
import { Environment } from './config/env.validation';
import { shouldExposeSwagger } from './config/swagger.config';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
export function configureApp(app: INestApplication): void {
const config = app.get<ConfigService<Environment, true>>(ConfigService);
const frontendOrigin = config.getOrThrow<string>('FRONTEND_ORIGIN');
app.enableCors({ origin: frontendOrigin, credentials: true });
app.useLogger(app.get(Logger));
app.enableCors({
origin: frontendOrigin,
credentials: true,
exposedHeaders: ['X-Request-ID'],
});
app.setGlobalPrefix('api/v1');
app.use(cookieParser());
const requestLogging = app.get(RequestLoggingMiddleware);
app.use(requestLogging.use.bind(requestLogging));
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.useGlobalFilters(new HttpExceptionFilter());
app.enableShutdownHooks();
}
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
configureApp(app);
const config = app.get<ConfigService<Environment, true>>(ConfigService);
if (shouldExposeSwagger(process.env['NODE_ENV'])) {
const document = SwaggerModule.createDocument(
@@ -31,5 +47,9 @@ async function bootstrap() {
}
await app.listen(config.getOrThrow('PORT'));
app.get(StructuredLogger).info('application.started', {
service: 'wealth-ledger-backend',
environment: process.env['NODE_ENV'] ?? 'development',
});
}
void bootstrap();
if (require.main === module) void bootstrap();
@@ -19,10 +19,12 @@ describe('BrokerageOperationsService', () => {
getOpenAccounts: jest.fn(),
getOperationsByCursor: jest.fn(),
};
const requestContext = { set: jest.fn() };
const service = new BrokerageOperationsService(
prisma as never,
config as never,
tbank as never,
requestContext as never,
);
const crypto = TbankCrypto.fromBase64(key);
let operationsRequest: unknown;
@@ -5,6 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { RequestContextService } from '../common/logging/request-context.service';
import { Environment } from '../config/env.validation';
import { PrismaService } from '../prisma/prisma.service';
import {
@@ -38,6 +39,7 @@ export class BrokerageOperationsService {
private readonly prisma: PrismaService,
config: ConfigService<Environment, true>,
private readonly tbank: TbankClient,
private readonly requestContext: RequestContextService,
) {
this.crypto = TbankCrypto.fromBase64(
config.getOrThrow('TOKEN_ENCRYPTION_KEY'),
@@ -49,6 +51,7 @@ export class BrokerageOperationsService {
accountId: string,
query: BrokerageOperationsQueryDto,
): Promise<BrokerageOperationsPageDto> {
this.requestContext.set({ userId, brokerageAccountId: accountId });
const range = this.range(query);
const key = JSON.stringify({ userId, accountId, ...query, ...range });
const cached = this.pages.get(key);
+4
View File
@@ -23,10 +23,14 @@ describe('PortfolioService', () => {
getInstrumentByFigi: jest.fn(),
getInstrumentByUid: jest.fn(),
};
const requestContext = { set: jest.fn() };
const logger = { event: jest.fn() };
const service = new PortfolioService(
prisma as never,
config as never,
tbank as never,
requestContext as never,
logger as never,
);
const crypto = TbankCrypto.fromBase64(key);
+7 -8
View File
@@ -2,12 +2,13 @@ import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Environment } from '../config/env.validation';
import { RequestContextService } from '../common/logging/request-context.service';
import { StructuredLogger } from '../common/logging/structured-logger.service';
import { PrismaService } from '../prisma/prisma.service';
import {
add,
@@ -49,7 +50,6 @@ type CachedMetadata = {
@Injectable()
export class PortfolioService {
private readonly logger = new Logger(PortfolioService.name);
private readonly crypto: TbankCrypto;
private readonly snapshots = new Map<string, CachedSnapshot>();
private readonly metadata = new Map<string, CachedMetadata>();
@@ -58,6 +58,8 @@ export class PortfolioService {
private readonly prisma: PrismaService,
config: ConfigService<Environment, true>,
private readonly tbank: TbankClient,
private readonly requestContext: RequestContextService,
private readonly logger: StructuredLogger,
) {
this.crypto = TbankCrypto.fromBase64(
config.getOrThrow('TOKEN_ENCRYPTION_KEY'),
@@ -65,6 +67,7 @@ export class PortfolioService {
}
async get(userId: string, accountId: string): Promise<PortfolioDto> {
this.requestContext.set({ userId, brokerageAccountId: accountId });
const key = `${userId}:${accountId}`;
const cached = this.snapshots.get(key);
if (cached && Date.now() - cached.storedAt <= freshForMs)
@@ -241,16 +244,12 @@ export class PortfolioService {
try {
const value = await fetch();
this.metadata.set(key, { value, storedAt: Date.now() });
this.logger.debug({
event: 'instrument_metadata_resolved',
cacheKey: key,
this.logger.event('debug', 'portfolio.instrument-metadata.resolved', {
found: Boolean(value?.name?.trim() || value?.ticker?.trim()),
});
return value;
} catch (error) {
this.logger.warn({
event: 'instrument_metadata_failed',
cacheKey: key,
this.logger.event('warn', 'portfolio.instrument-metadata.failed', {
code:
error instanceof Error && 'response' in error
? ((error as { response?: { code?: string } }).response?.code ??
+56
View File
@@ -1,4 +1,5 @@
import { ServiceUnavailableException } from '@nestjs/common';
import { status } from '@grpc/grpc-js';
import { TbankClient } from './tbank.client';
describe('TbankClient operations', () => {
@@ -42,4 +43,59 @@ describe('TbankClient operations', () => {
error,
);
});
it('logs success, retry, and final failure with the RPC context', async () => {
jest.useFakeTimers();
const logger = { event: jest.fn(), info: jest.fn() };
const client = Object.create(TbankClient.prototype) as TbankClient;
(client as unknown as { logger: unknown }).logger = logger;
(client as unknown as { queue: unknown }).queue = {
size: 0,
run: (operation: () => unknown) => operation(),
};
const invoke = (...args: [string, unknown, string, object]) =>
(
client as unknown as {
call: (
token: string,
service: unknown,
method: string,
input: object,
) => Promise<unknown>;
}
).call(...args);
const requestRpc = jest
.fn()
.mockResolvedValueOnce({ ok: true })
.mockRejectedValueOnce({ code: status.UNAVAILABLE })
.mockRejectedValueOnce({ code: status.UNAVAILABLE });
(client as unknown as { request: jest.Mock }).request = requestRpc;
await expect(invoke('token', {}, 'getPortfolio', {})).resolves.toEqual({
ok: true,
});
const failed = invoke('token', {}, 'getPortfolio', {});
const rejected = expect(failed).rejects.toEqual(
expect.any(ServiceUnavailableException),
);
await jest.advanceTimersByTimeAsync(1000);
await rejected;
expect(logger.event).toHaveBeenCalledWith(
'debug',
'tbank.rpc.completed',
expect.objectContaining({ operation: 'getPortfolio', attempts: 1 }),
);
expect(logger.event).toHaveBeenCalledWith(
'warn',
'tbank.rpc.retry',
expect.objectContaining({ grpcCode: status.UNAVAILABLE, attempts: 1 }),
);
expect(logger.event).toHaveBeenCalledWith(
'error',
'tbank.rpc.failed',
expect.objectContaining({ grpcCode: status.UNAVAILABLE, attempts: 2 }),
);
jest.useRealTimers();
});
});
+56 -46
View File
@@ -1,8 +1,4 @@
import {
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
ChannelCredentials,
@@ -17,6 +13,7 @@ import { loadSync } from '@grpc/proto-loader';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { Environment } from '../config/env.validation';
import { StructuredLogger } from '../common/logging/structured-logger.service';
const requestDeadlineMs = 15_000;
@@ -129,13 +126,15 @@ class RateLimiter {
@Injectable()
export class TbankClient {
private readonly logger = new Logger(TbankClient.name);
private readonly queue: RateLimiter;
private readonly users: TbankClientService;
private readonly operations: TbankClientService;
private readonly instruments: TbankClientService;
constructor(config: ConfigService<Environment, true>) {
constructor(
config: ConfigService<Environment, true>,
private readonly logger: StructuredLogger,
) {
const sourceProtoRoot = join(process.cwd(), 'src/tbank/proto/contracts');
const protoRoot = existsSync(sourceProtoRoot)
? sourceProtoRoot
@@ -174,14 +173,7 @@ export class TbankClient {
this.queue = new RateLimiter(
config.getOrThrow<number>('TBANK_RATE_LIMIT_PER_SECOND'),
);
this.logger.log({
event: 'client_configured',
endpoint,
rateLimitPerSecond: config.getOrThrow<number>(
'TBANK_RATE_LIMIT_PER_SECOND',
),
customCa: Boolean(caPath),
});
this.logger.info('tbank.client.configured');
}
async validateToken(token: string): Promise<void> {
@@ -242,41 +234,59 @@ export class TbankClient {
client: TbankClientService,
method: string,
request: object,
retry = true,
): Promise<Response> {
if (this.queue.size >= 50)
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
try {
return await this.queue.run(() =>
this.request<Response>(token, client, method, request),
);
} catch (error) {
const code = (error as ServiceError).code;
const retryable = [
status.UNAVAILABLE,
status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED,
].includes(code);
this.logger.warn({
event: 'tbank_rpc_failed',
method,
grpcCode: code,
retryable,
...(method === 'getInstrumentBy'
? {
instrumentIdType: (request as { idType?: string }).idType,
instrumentId: (request as { id?: string }).id,
}
: {}),
if (this.queue.size >= 50) {
this.logger.event('error', 'tbank.rpc.failed', {
operation: method,
code: 'TBANK_UNAVAILABLE',
attempts: 0,
});
if (retry && retryable) {
await new Promise((resolve) => setTimeout(resolve, 1000));
return this.call(token, client, method, request, false);
}
if ([status.UNAUTHENTICATED, status.PERMISSION_DENIED].includes(code))
throw new Error('INVALID_TBANK_TOKEN');
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
}
for (let attempts = 1; attempts <= 2; attempts += 1) {
const startedAt = performance.now();
try {
const response = await this.queue.run(() =>
this.request<Response>(token, client, method, request),
);
this.logger.event('debug', 'tbank.rpc.completed', {
operation: method,
attempts,
durationMs: Math.round(performance.now() - startedAt),
});
return response;
} catch (error) {
const grpcCode = (error as ServiceError).code;
const retryable = [
status.UNAVAILABLE,
status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED,
].includes(grpcCode);
if (retryable && attempts < 2) {
this.logger.event('warn', 'tbank.rpc.retry', {
operation: method,
grpcCode,
attempts,
durationMs: Math.round(performance.now() - startedAt),
});
await new Promise((resolve) => setTimeout(resolve, 1000));
continue;
}
this.logger.event('error', 'tbank.rpc.failed', {
operation: method,
grpcCode,
attempts,
durationMs: Math.round(performance.now() - startedAt),
});
if (
[status.UNAUTHENTICATED, status.PERMISSION_DENIED].includes(grpcCode)
)
throw new Error('INVALID_TBANK_TOKEN');
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
}
}
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
}
private request<Response>(
@@ -22,11 +22,15 @@ describe('UpcomingEventsService', () => {
getInstrumentByFigi: jest.fn(),
};
const moex = { dividends: jest.fn(), bondEvents: jest.fn() };
const requestContext = { set: jest.fn() };
const logger = { info: jest.fn(), event: jest.fn() };
const service = new UpcomingEventsService(
prisma as never,
config as never,
tbank as never,
moex as never,
requestContext as never,
logger as never,
);
const crypto = TbankCrypto.fromBase64(key);
+13 -34
View File
@@ -2,11 +2,12 @@ import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Environment } from '../config/env.validation';
import { RequestContextService } from '../common/logging/request-context.service';
import { StructuredLogger } from '../common/logging/structured-logger.service';
import { PrismaService } from '../prisma/prisma.service';
import { decimal, formatDecimal, isZero, multiply } from './decimal';
import { MoexBondEvent, MoexClient, MoexDividend } from './moex.client';
@@ -26,13 +27,14 @@ type Position = NonNullable<TbankPortfolio['positions']>[number];
@Injectable()
export class UpcomingEventsService {
private readonly crypto: TbankCrypto;
private readonly logger = new Logger(UpcomingEventsService.name);
constructor(
private readonly prisma: PrismaService,
config: ConfigService<Environment, true>,
private readonly tbank: TbankClient,
private readonly moex: MoexClient,
private readonly requestContext: RequestContextService,
private readonly logger: StructuredLogger,
) {
this.crypto = TbankCrypto.fromBase64(
config.getOrThrow('TOKEN_ENCRYPTION_KEY'),
@@ -40,6 +42,7 @@ export class UpcomingEventsService {
}
async get(userId: string, accountId: string): Promise<UpcomingEventsDto> {
this.requestContext.set({ userId, brokerageAccountId: accountId });
try {
const connection = await this.prisma.tbankConnection.findUnique({
where: { userId },
@@ -67,13 +70,8 @@ export class UpcomingEventsService {
.filter(
(event) => event.date >= window.from && event.date <= window.to,
);
this.logger.log({
event: 'upcoming_events_calculated',
accountId,
from: window.from,
to: window.to,
positionCount: (portfolio.positions ?? []).length,
eventCount: projected.length,
this.logger.info('upcoming-events.calculated', {
count: projected.length,
});
return {
accountId,
@@ -98,13 +96,8 @@ export class UpcomingEventsService {
): Promise<UpcomingEventDto[]> {
const ticker = position.ticker?.trim();
const instrument = ticker
? await this.instrument(token, position).catch((error: unknown) => {
this.logger.warn({
event: 'upcoming_event_metadata_unavailable',
figi: position.figi?.trim() || null,
ticker,
error: error instanceof Error ? error.message : 'UNKNOWN',
});
? await this.instrument(token, position).catch(() => {
this.logger.event('warn', 'upcoming-events.metadata-unavailable');
return null;
})
: await this.instrument(token, position);
@@ -117,30 +110,16 @@ export class UpcomingEventsService {
displayName: instrument?.name?.trim() || resolvedTicker,
};
const quantity = decimal(position.quantity ?? {});
this.logger.debug({
event: 'upcoming_event_position_resolved',
figi: metadata.figi,
portfolioTicker: ticker || null,
resolvedTicker,
displayName: metadata.displayName,
instrumentType: position.instrumentType?.toLowerCase() || null,
});
if (position.instrumentType?.toLowerCase() === 'share') {
const dividends = await this.moex.dividends(resolvedTicker);
this.logger.debug({
event: 'upcoming_event_source_result',
ticker: resolvedTicker,
source: 'dividends',
eventCount: dividends.length,
this.logger.event('debug', 'upcoming-events.source-result', {
count: dividends.length,
});
return dividends.map((event) => this.dividend(metadata, quantity, event));
}
const events = await this.moex.bondEvents(resolvedTicker);
this.logger.debug({
event: 'upcoming_event_source_result',
ticker: resolvedTicker,
source: 'bondization',
eventCount: events.length,
this.logger.event('debug', 'upcoming-events.source-result', {
count: events.length,
});
return events.map((event) => this.bond(metadata, quantity, event));
}