feat(observability): add exception filter, logging interceptor, and CodeExecutorModule

- add HttpExceptionFilter logging BadRequestException at warn and InternalServerErrorException at error with cause chain
- add LoggingInterceptor logging request/response pairs at debug level with method, path, body, status, and duration
- extract CodeExecutorService into standalone CodeExecutorModule imported by BrowserModule and McpModule
- thread { cause: err } into all catch blocks across auth, browser, and code-executor services
This commit is contained in:
2026-04-07 13:49:02 +03:00
parent bf1b6249a9
commit 5ac26ecb3a
13 changed files with 168 additions and 19 deletions
+12 -1
View File
@@ -17,6 +17,7 @@
"@nestjs/platform-express": "^11.1.18",
"@nestjs/swagger": "^11.2.6",
"@nestjs/typeorm": "^11.0.1",
"acorn": "^8.16.0",
"better-sqlite3": "^12.8.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
@@ -30,6 +31,7 @@
},
"devDependencies": {
"@nestjs/cli": "^11.0.17",
"@types/acorn": "^4.0.6",
"@types/better-sqlite3": "^7.6.13",
"@types/jsdom": "^28.0.1",
"@types/mozilla__readability": "^0.4.2",
@@ -1377,6 +1379,16 @@
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@types/acorn": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@types/acorn/-/acorn-4.0.6.tgz",
"integrity": "sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@@ -1678,7 +1690,6 @@
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
+2
View File
@@ -21,6 +21,7 @@
"@nestjs/platform-express": "^11.1.18",
"@nestjs/swagger": "^11.2.6",
"@nestjs/typeorm": "^11.0.1",
"acorn": "^8.16.0",
"better-sqlite3": "^12.8.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
@@ -34,6 +35,7 @@
},
"devDependencies": {
"@nestjs/cli": "^11.0.17",
"@types/acorn": "^4.0.6",
"@types/better-sqlite3": "^7.6.13",
"@types/jsdom": "^28.0.1",
"@types/mozilla__readability": "^0.4.2",
+3 -2
View File
@@ -60,8 +60,8 @@ export class AuthService {
let descriptor: KeyDescriptor;
try {
descriptor = JSON.parse(fs.readFileSync(keyJsonPath, 'utf-8'));
} catch {
throw new BadRequestException(`Cannot read key descriptor: ${keyId}`);
} catch (err) {
throw new BadRequestException(`Cannot read key descriptor: ${keyId}`, { cause: err });
}
const keyFilePath = path.resolve(this.keysDir, descriptor.keyFile);
@@ -130,6 +130,7 @@ export class AuthService {
this.logger.error(`Login failed: ${(err as Error).message}`);
throw new InternalServerErrorException(
`Login automation failed: ${(err as Error).message}`,
{ cause: err },
);
} finally {
await browser.close();
+6 -1
View File
@@ -1,13 +1,17 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { BrowserService, ExecResult, OpenResult } from './browser.service';
import { CodeExecutorService } from '../code-executor/code-executor.service';
import { OpenDto } from './dto/open.dto';
import { ExecDto } from './dto/exec.dto';
@ApiTags('browser')
@Controller()
export class BrowserController {
constructor(private readonly browserService: BrowserService) {}
constructor(
private readonly browserService: BrowserService,
private readonly codeExecutor: CodeExecutorService,
) {}
@Post('open')
@ApiOperation({ summary: 'Open a URL with a stored session (cookies + localStorage)' })
@@ -43,6 +47,7 @@ export class BrowserController {
@ApiResponse({ status: 404, description: 'Session not found' })
@ApiResponse({ status: 500, description: 'Execution failed' })
exec(@Body() dto: ExecDto): Promise<ExecResult> {
this.codeExecutor.validate(dto.code);
return this.browserService.exec(dto.sessionName, dto.code, dto.url);
}
}
+2 -1
View File
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { BrowserController } from './browser.controller';
import { BrowserService } from './browser.service';
import { SessionModule } from '../session/session.module';
import { CodeExecutorModule } from '../code-executor/code-executor.module';
@Module({
imports: [SessionModule],
imports: [SessionModule, CodeExecutorModule],
controllers: [BrowserController],
providers: [BrowserService],
exports: [BrowserService],
+16 -13
View File
@@ -9,6 +9,10 @@ import { chromium } from 'playwright';
import { Readability } from '@mozilla/readability';
import { JSDOM } from 'jsdom';
import { SessionService } from '../session/session.service';
import { CodeExecutorService } from '../code-executor/code-executor.service';
import type { ExecResult } from '../code-executor/code-executor.service';
export type { ExecResult } from '../code-executor/code-executor.service';
export interface OpenResult {
url: string;
@@ -16,15 +20,14 @@ export interface OpenResult {
content: string;
}
export interface ExecResult {
result: unknown;
}
@Injectable()
export class BrowserService {
private readonly logger = new Logger(BrowserService.name);
constructor(private readonly sessionService: SessionService) {}
constructor(
private readonly sessionService: SessionService,
private readonly codeExecutor: CodeExecutorService,
) {}
async open(sessionName: string, url: string, readerMode = false): Promise<OpenResult> {
const session = await this.sessionService.findBySessionName(sessionName);
@@ -37,8 +40,8 @@ export class BrowserService {
try {
cookies = JSON.parse(session.cookies);
localStorageData = JSON.parse(session.localStorage);
} catch {
throw new InternalServerErrorException('Failed to deserialize session data');
} catch (err) {
throw new InternalServerErrorException('Failed to deserialize session data', { cause: err });
}
const browser = await chromium.launch({ headless: true });
@@ -87,6 +90,7 @@ export class BrowserService {
this.logger.error(`[${sessionName}] Open failed: ${(err as Error).message}`);
throw new InternalServerErrorException(
`Browser open failed: ${(err as Error).message}`,
{ cause: err },
);
} finally {
await browser.close();
@@ -104,8 +108,8 @@ export class BrowserService {
try {
cookies = JSON.parse(session.cookies);
localStorageData = JSON.parse(session.localStorage);
} catch {
throw new InternalServerErrorException('Failed to deserialize session data');
} catch (err) {
throw new InternalServerErrorException('Failed to deserialize session data', { cause: err });
}
const browser = await chromium.launch({ headless: true });
@@ -125,13 +129,11 @@ export class BrowserService {
await page.goto(url, { waitUntil: 'networkidle' });
}
// eslint-disable-next-line no-new-func
const fn = new Function('page', 'context', `return (async (page, context) => { ${code} })(page, context)`);
this.logger.log(`[${sessionName}] exec: running user code`);
const result = await fn(page, context);
const result = await this.codeExecutor.execute(page, context, code);
this.logger.log(`[${sessionName}] exec: done`);
return { result };
return result;
} catch (err) {
if (
err instanceof BadRequestException ||
@@ -143,6 +145,7 @@ export class BrowserService {
this.logger.error(`[${sessionName}] exec failed: ${(err as Error).message}`);
throw new InternalServerErrorException(
`Browser exec failed: ${(err as Error).message}`,
{ cause: err },
);
} finally {
await browser.close();
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { CodeExecutorService } from './code-executor.service';
@Module({
providers: [CodeExecutorService],
exports: [CodeExecutorService],
})
export class CodeExecutorModule {}
@@ -0,0 +1,41 @@
import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { parse } from 'acorn';
import type { Page, BrowserContext } from 'playwright';
export interface ExecResult {
result: unknown;
}
@Injectable()
export class CodeExecutorService {
private readonly logger = new Logger(CodeExecutorService.name);
/**
* Validates `code` by wrapping it in an async function body and attempting
* to parse it with acorn. Throws BadRequestException if it is not valid JS.
*/
validate(code: string): void {
const wrapped = `async function __validate__(page, context) { ${code} }`;
try {
parse(wrapped, { ecmaVersion: 2022 });
} catch (err) {
throw new BadRequestException(`Code parse error: ${(err as Error).message}`, { cause: err });
}
}
/**
* Executes `code` as an async function body with `page` and `context` in
* scope. Always call `validate()` before this method.
*/
async execute(page: Page, context: BrowserContext, code: string): Promise<ExecResult> {
try {
// eslint-disable-next-line no-new-func
const fn = new Function('page', 'context', `return (async (page, context) => { ${code} })(page, context)`);
this.logger.debug('Executing user code');
const result = await fn(page, context);
return { result };
} catch (err) {
throw new InternalServerErrorException(`Code execution failed: ${(err as Error).message}`, { cause: err });
}
}
}
+38
View File
@@ -0,0 +1,38 @@
import {
ArgumentsHost,
BadRequestException,
Catch,
ExceptionFilter,
HttpException,
InternalServerErrorException,
Logger,
} from '@nestjs/common';
import type { Request, Response } from 'express';
@Catch(BadRequestException, InternalServerErrorException)
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: HttpException, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
const status = exception.getStatus();
const body = exception.getResponse();
const cause = (exception as any).cause as Error | undefined;
const causeMessage = cause ? ` | cause: ${cause.message}` : '';
const message = `${exception.message}${causeMessage}`;
if (status >= 500) {
this.logger.error(
`[${request.method} ${request.url}] ${status}${message}`,
cause?.stack ?? exception.stack,
);
} else {
this.logger.warn(`[${request.method} ${request.url}] ${status}${message}`);
}
response.status(status).json(body);
}
}
+31
View File
@@ -0,0 +1,31 @@
import {
CallHandler,
ExecutionContext,
Injectable,
Logger,
NestInterceptor,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { Observable, tap } from 'rxjs';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger(LoggingInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const http = context.switchToHttp();
const req = http.getRequest<Request>();
const res = http.getResponse<Response>();
const { method, url, body } = req;
const start = Date.now();
this.logger.debug(`${method} ${url} ${JSON.stringify(body)}`);
return next.handle().pipe(
tap((responseBody) => {
const ms = Date.now() - start;
this.logger.debug(`${method} ${url} ${res.statusCode} (${ms}ms) ${JSON.stringify(responseBody)}`);
}),
);
}
}
+4
View File
@@ -3,6 +3,8 @@ import { ConfigService } from '@nestjs/config';
import { Logger, ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './filters/http-exception.filter';
import { LoggingInterceptor } from './interceptors/logging.interceptor';
import { name as pkgName, version as pkgVersion } from '../package.json';
async function bootstrap() {
@@ -10,6 +12,8 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ transform: true }));
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new LoggingInterceptor());
const config = app.get(ConfigService);
const port = config.get<number>('PORT', 3000);
+2 -1
View File
@@ -5,9 +5,10 @@ import { AuthModule } from '../auth/auth.module';
import { SessionModule } from '../session/session.module';
import { EnvironmentModule } from '../environment/environment.module';
import { BrowserModule } from '../browser/browser.module';
import { CodeExecutorModule } from '../code-executor/code-executor.module';
@Module({
imports: [AuthModule, SessionModule, EnvironmentModule, BrowserModule],
imports: [AuthModule, SessionModule, EnvironmentModule, BrowserModule, CodeExecutorModule],
controllers: [McpController],
providers: [McpService],
})
+3
View File
@@ -8,6 +8,7 @@ import { SessionService } from '../session/session.service';
import { EnvironmentService } from '../environment/environment.service';
import type { EnvironmentUrls } from '../environment/environment.entity';
import { BrowserService } from '../browser/browser.service';
import { CodeExecutorService } from '../code-executor/code-executor.service';
@Injectable()
export class McpService {
@@ -16,6 +17,7 @@ export class McpService {
private readonly sessionService: SessionService,
private readonly environmentService: EnvironmentService,
private readonly browserService: BrowserService,
private readonly codeExecutor: CodeExecutorService,
) {}
async handle(req: Request, res: Response): Promise<void> {
@@ -197,6 +199,7 @@ export class McpService {
},
async ({ sessionName, code, url }) => {
try {
this.codeExecutor.validate(code);
const result = await this.browserService.exec(sessionName, code, url);
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] };
} catch (err) {