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:
Generated
+12
-1
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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],
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user