From 5ac26ecb3a889c60e9375fd258cae18c6271d54c Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Tue, 7 Apr 2026 13:49:02 +0300 Subject: [PATCH] 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 --- package-lock.json | 13 ++++++- package.json | 2 ++ src/auth/auth.service.ts | 5 +-- src/browser/browser.controller.ts | 7 +++- src/browser/browser.module.ts | 3 +- src/browser/browser.service.ts | 29 ++++++++------- src/code-executor/code-executor.module.ts | 8 +++++ src/code-executor/code-executor.service.ts | 41 ++++++++++++++++++++++ src/filters/http-exception.filter.ts | 38 ++++++++++++++++++++ src/interceptors/logging.interceptor.ts | 31 ++++++++++++++++ src/main.ts | 4 +++ src/mcp/mcp.module.ts | 3 +- src/mcp/mcp.service.ts | 3 ++ 13 files changed, 168 insertions(+), 19 deletions(-) create mode 100644 src/code-executor/code-executor.module.ts create mode 100644 src/code-executor/code-executor.service.ts create mode 100644 src/filters/http-exception.filter.ts create mode 100644 src/interceptors/logging.interceptor.ts diff --git a/package-lock.json b/package-lock.json index a1b16e6..b0700d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index bdf7f93..15c3b6d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index de0dc6e..ea5c8a5 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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(); diff --git a/src/browser/browser.controller.ts b/src/browser/browser.controller.ts index 95f59cc..2e6f13b 100644 --- a/src/browser/browser.controller.ts +++ b/src/browser/browser.controller.ts @@ -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 { + this.codeExecutor.validate(dto.code); return this.browserService.exec(dto.sessionName, dto.code, dto.url); } } diff --git a/src/browser/browser.module.ts b/src/browser/browser.module.ts index 8ff9d42..9db3ccd 100644 --- a/src/browser/browser.module.ts +++ b/src/browser/browser.module.ts @@ -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], diff --git a/src/browser/browser.service.ts b/src/browser/browser.service.ts index 4f7be3b..5a92420 100644 --- a/src/browser/browser.service.ts +++ b/src/browser/browser.service.ts @@ -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 { 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(); diff --git a/src/code-executor/code-executor.module.ts b/src/code-executor/code-executor.module.ts new file mode 100644 index 0000000..0b10a60 --- /dev/null +++ b/src/code-executor/code-executor.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { CodeExecutorService } from './code-executor.service'; + +@Module({ + providers: [CodeExecutorService], + exports: [CodeExecutorService], +}) +export class CodeExecutorModule {} diff --git a/src/code-executor/code-executor.service.ts b/src/code-executor/code-executor.service.ts new file mode 100644 index 0000000..ede7b1a --- /dev/null +++ b/src/code-executor/code-executor.service.ts @@ -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 { + 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 }); + } + } +} diff --git a/src/filters/http-exception.filter.ts b/src/filters/http-exception.filter.ts new file mode 100644 index 0000000..0df2303 --- /dev/null +++ b/src/filters/http-exception.filter.ts @@ -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(); + const response = ctx.getResponse(); + 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); + } +} diff --git a/src/interceptors/logging.interceptor.ts b/src/interceptors/logging.interceptor.ts new file mode 100644 index 0000000..e894272 --- /dev/null +++ b/src/interceptors/logging.interceptor.ts @@ -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 { + const http = context.switchToHttp(); + const req = http.getRequest(); + const res = http.getResponse(); + 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)}`); + }), + ); + } +} diff --git a/src/main.ts b/src/main.ts index 4323dca..33795ba 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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('PORT', 3000); diff --git a/src/mcp/mcp.module.ts b/src/mcp/mcp.module.ts index fda2d92..21d521a 100644 --- a/src/mcp/mcp.module.ts +++ b/src/mcp/mcp.module.ts @@ -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], }) diff --git a/src/mcp/mcp.service.ts b/src/mcp/mcp.service.ts index cc979ac..51c74c2 100644 --- a/src/mcp/mcp.service.ts +++ b/src/mcp/mcp.service.ts @@ -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 { @@ -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) {