diff --git a/src/app.module.ts b/src/app.module.ts index 5b25679..066fa64 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { SearchModule } from './search/search.module'; +import { DebuggingModule } from './debugging/debugging.module'; @Module({ imports: [ SearchModule, + DebuggingModule, ], controllers: [AppController], providers: [], diff --git a/src/debugging/README.md b/src/debugging/README.md new file mode 100644 index 0000000..8f48bef --- /dev/null +++ b/src/debugging/README.md @@ -0,0 +1,62 @@ +# Developer Debugging Toolkit + +In-process tools that make reproducing and diagnosing HTTP bugs fast, without +standing up an external APM. Everything is **ephemeral and process-local** — a +bounded in-memory ring buffer, never persisted. + +## Capabilities + +| Capability | Where | +| --------------------------- | -------------------------------------------- | +| Request/response inspection | `RequestCaptureService` + `GET /debug/requests/:id` | +| Request replay (+ diff) | `RequestReplayService` + `POST /debug/requests/:id/replay` | +| Performance timeline | `PerformanceTimelineService` + `GET /debug/requests/:id/timeline` | +| Stack-trace enhancement | `StackTraceService` + `GET /debug/requests/:id/trace` | + +## How capture works + +`DebugCaptureMiddleware` runs on every route (except `/debug/*`). For each +request it: + +1. Assigns a `x-debug-id` and attaches a `TimelineRecorder` to `req.timeline`. +2. Patches `res.json`/`res.send` to buffer the response body. +3. On `finish`/`error`, builds the timeline, enhances any error, redacts + sensitive headers, truncates oversized bodies and stores the record. + +Downstream services can enrich the timeline: + +```ts +await req.timeline?.measure('db.findCourses', () => repo.find()); +``` + +## Enabling + +The capture middleware mounts automatically when `NODE_ENV !== 'production'`. +Force it on elsewhere (e.g. staging) with `DEBUG_CAPTURE=true`. Replays target +the local instance by default; override with `DEBUG_REPLAY_BASE_URL` or the +`baseUrl` field in the replay body. + +## API (admin only) + +| Method & path | Description | +| ---------------------------------- | ---------------------------------------- | +| `GET /debug/requests` | List recent exchanges (summaries) | +| `GET /debug/requests/:id` | Full captured request + response | +| `GET /debug/requests/:id/timeline` | Timeline with slowest-span hotspots | +| `GET /debug/requests/:id/trace` | Enhanced structured stack trace | +| `POST /debug/requests/:id/replay` | Replay and diff against the original | +| `DELETE /debug/requests` | Clear the capture buffer | + +All endpoints require an authenticated `ADMIN` because captured traffic can +contain sensitive payloads. Sensitive headers (`authorization`, `cookie`, …) +are redacted at capture time; supply fresh credentials via `headerOverrides` +when replaying. + +## Wiring + +Import `DebuggingModule` into `AppModule`: + +```ts +@Module({ imports: [DebuggingModule] }) +export class AppModule {} +``` diff --git a/src/debugging/debug.controller.ts b/src/debugging/debug.controller.ts new file mode 100644 index 0000000..0d778d6 --- /dev/null +++ b/src/debugging/debug.controller.ts @@ -0,0 +1,111 @@ +import { + Body, + Controller, + Delete, + Get, + NotFoundException, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { UserRole } from '../users/entities/user.entity'; +import { RequestCaptureService } from './services/request-capture.service'; +import { RequestReplayService } from './services/request-replay.service'; +import { PerformanceTimelineService } from './services/performance-timeline.service'; +import { ReplayRequestDto } from './dto/replay-request.dto'; + +/** + * Developer-facing debugging API. Restricted to admins because captured + * traffic can contain sensitive payloads. + */ +@ApiTags('debugging') +@Controller('debug') +@UseGuards(JwtAuthGuard, RolesGuard) +@ApiBearerAuth() +export class DebugController { + constructor( + private readonly capture: RequestCaptureService, + private readonly replayService: RequestReplayService, + private readonly timelines: PerformanceTimelineService, + ) {} + + @Get('requests') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'List recently captured request/response exchanges' }) + list(@Query('limit') limit?: string) { + const parsed = limit ? Number(limit) : undefined; + const records = this.capture.list( + Number.isFinite(parsed) ? parsed : undefined, + ); + // Summaries only — keep the list view light. Full payload via :id. + return { + total: this.capture.size, + records: records.map((r) => ({ + id: r.id, + timestamp: r.timestamp, + method: r.request.method, + path: r.request.path, + statusCode: r.response?.statusCode, + durationMs: r.timeline.totalDurationMs, + hasError: Boolean(r.error), + })), + }; + } + + @Get('requests/:id') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Inspect a captured request/response in full' }) + inspect(@Param('id') id: string) { + const record = this.capture.get(id); + if (!record) throw new NotFoundException(`No captured request "${id}"`); + return record; + } + + @Get('requests/:id/timeline') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Get the performance timeline for a captured request' }) + timeline(@Param('id') id: string) { + const record = this.capture.get(id); + if (!record) throw new NotFoundException(`No captured request "${id}"`); + return { + ...record.timeline, + hotspots: this.timelines.hotspots(record.timeline), + }; + } + + @Get('requests/:id/trace') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Get the enhanced stack trace for a failed request' }) + trace(@Param('id') id: string) { + const record = this.capture.get(id); + if (!record) throw new NotFoundException(`No captured request "${id}"`); + if (!record.error) { + return { message: 'Request completed without an error' }; + } + return record.error; + } + + @Post('requests/:id/replay') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Replay a captured request and diff the response' }) + replay(@Param('id') id: string, @Body() body: ReplayRequestDto) { + return this.replayService.replay(id, { + baseUrl: body?.baseUrl, + headerOverrides: body?.headerOverrides, + bodyOverride: body?.bodyOverride, + }); + } + + @Delete('requests') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Clear the captured request buffer' }) + clear() { + this.capture.clear(); + return { message: 'Debug capture buffer cleared' }; + } +} diff --git a/src/debugging/debugging.module.ts b/src/debugging/debugging.module.ts new file mode 100644 index 0000000..cbb3f5c --- /dev/null +++ b/src/debugging/debugging.module.ts @@ -0,0 +1,53 @@ +import { + MiddlewareConsumer, + Module, + NestModule, + RequestMethod, +} from '@nestjs/common'; +import { DebugController } from './debug.controller'; +import { RequestCaptureService } from './services/request-capture.service'; +import { RequestReplayService } from './services/request-replay.service'; +import { PerformanceTimelineService } from './services/performance-timeline.service'; +import { StackTraceService } from './services/stack-trace.service'; +import { DebugCaptureMiddleware } from './middleware/debug-capture.middleware'; + +/** + * DebuggingModule provides the developer debugging toolkit: + * - request/response capture & inspection + * - request replay with response diffing + * - per-request performance timelines + * - enhanced (structured) stack traces + * + * The capture middleware is only mounted outside production so it never adds + * overhead to or retains payloads from real traffic. Set DEBUG_CAPTURE=true to + * force-enable it (e.g. in staging) regardless of NODE_ENV. + */ +@Module({ + controllers: [DebugController], + providers: [ + RequestCaptureService, + RequestReplayService, + PerformanceTimelineService, + StackTraceService, + ], + exports: [ + RequestCaptureService, + PerformanceTimelineService, + StackTraceService, + ], +}) +export class DebuggingModule implements NestModule { + configure(consumer: MiddlewareConsumer): void { + const enabled = + process.env.DEBUG_CAPTURE === 'true' || + (process.env.NODE_ENV ?? 'development') !== 'production'; + + if (!enabled) return; + + consumer + .apply(DebugCaptureMiddleware) + // Don't capture the debugger's own endpoints — avoids recursive noise. + .exclude({ path: 'debug/(.*)', method: RequestMethod.ALL }) + .forRoutes({ path: '*', method: RequestMethod.ALL }); + } +} diff --git a/src/debugging/dto/replay-request.dto.ts b/src/debugging/dto/replay-request.dto.ts new file mode 100644 index 0000000..afe6d32 --- /dev/null +++ b/src/debugging/dto/replay-request.dto.ts @@ -0,0 +1,27 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsObject, IsOptional, IsString } from 'class-validator'; + +/** Body accepted by the replay endpoint; all fields are optional overrides. */ +export class ReplayRequestDto { + @ApiPropertyOptional({ + description: 'Base URL to replay against. Defaults to the running instance.', + example: 'http://127.0.0.1:3000', + }) + @IsOptional() + @IsString() + baseUrl?: string; + + @ApiPropertyOptional({ + description: 'Header values merged onto (and overriding) the captured headers.', + example: { authorization: 'Bearer ' }, + }) + @IsOptional() + @IsObject() + headerOverrides?: Record; + + @ApiPropertyOptional({ + description: 'Replacement request body. When omitted the captured body is reused.', + }) + @IsOptional() + bodyOverride?: unknown; +} diff --git a/src/debugging/interfaces/debug.interfaces.ts b/src/debugging/interfaces/debug.interfaces.ts new file mode 100644 index 0000000..378497e --- /dev/null +++ b/src/debugging/interfaces/debug.interfaces.ts @@ -0,0 +1,113 @@ +/** + * Type definitions for the developer debugging toolkit. + * + * The toolkit captures HTTP traffic in-memory so developers can inspect, + * replay and profile requests without an external APM. All structures are + * intentionally serialisable so they can be returned verbatim over the wire. + */ + +/** A single phase recorded on a request's performance timeline. */ +export interface ITimelineSpan { + /** Human readable phase name, e.g. "db.query" or "controller". */ + name: string; + /** Milliseconds since the start of the request when the span opened. */ + startOffsetMs: number; + /** Span duration in milliseconds. */ + durationMs: number; + /** Optional free-form metadata captured with the span. */ + metadata?: Record; +} + +/** Aggregated performance timeline for one captured request. */ +export interface IPerformanceTimeline { + requestId: string; + /** Total wall-clock time from request received to response sent. */ + totalDurationMs: number; + spans: ITimelineSpan[]; +} + +/** Normalised representation of an enhanced error stack frame. */ +export interface IStackFrame { + functionName: string; + fileName: string; + lineNumber?: number; + columnNumber?: number; + /** True when the frame originates from the project's own source. */ + isApplicationCode: boolean; + /** True when the frame lives under node_modules. */ + isNodeModule: boolean; +} + +/** Enriched, structured view of an error and its stack trace. */ +export interface IEnhancedStackTrace { + name: string; + message: string; + frames: IStackFrame[]; + /** The single most likely culprit — first application frame. */ + origin?: IStackFrame; + /** Original raw stack string, preserved for completeness. */ + raw?: string; + /** Chained `cause` errors, recursively enhanced. */ + cause?: IEnhancedStackTrace; +} + +/** Snapshot of an incoming request, suitable for inspection and replay. */ +export interface ICapturedRequest { + method: string; + url: string; + /** Path without query string. */ + path: string; + query: Record; + headers: Record; + body?: unknown; + /** Remote address as reported by the framework. */ + ip?: string; +} + +/** Snapshot of the response produced for a captured request. */ +export interface ICapturedResponse { + statusCode: number; + headers: Record; + body?: unknown; +} + +/** A complete captured request/response exchange plus diagnostics. */ +export interface IDebugRecord { + id: string; + /** Correlation id propagated from request headers when present. */ + correlationId?: string; + timestamp: string; + request: ICapturedRequest; + response?: ICapturedResponse; + timeline: IPerformanceTimeline; + /** Present only when the request resulted in an error. */ + error?: IEnhancedStackTrace; +} + +/** Result of replaying a previously captured request. */ +export interface IReplayResult { + /** The record that was replayed. */ + sourceId: string; + /** Target the request was replayed against. */ + target: string; + statusCode: number; + durationMs: number; + headers: Record; + body?: unknown; + /** Differences between the original and replayed response, when available. */ + diff?: { + statusChanged: boolean; + originalStatus?: number; + replayedStatus: number; + }; +} + +/** Options controlling a replay. */ +export interface IReplayOptions { + /** Base URL to replay against. Defaults to the configured self target. */ + baseUrl?: string; + /** Header overrides merged onto the captured headers. */ + headerOverrides?: Record; + /** Replacement body; when omitted the captured body is reused. */ + bodyOverride?: unknown; +} diff --git a/src/debugging/middleware/debug-capture.middleware.ts b/src/debugging/middleware/debug-capture.middleware.ts new file mode 100644 index 0000000..9ad901a --- /dev/null +++ b/src/debugging/middleware/debug-capture.middleware.ts @@ -0,0 +1,111 @@ +import { Injectable, NestMiddleware, Logger } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { randomUUID } from 'crypto'; +import { RequestCaptureService } from '../services/request-capture.service'; +import { PerformanceTimelineService } from '../services/performance-timeline.service'; +import { StackTraceService } from '../services/stack-trace.service'; +import { ICapturedResponse, IDebugRecord } from '../interfaces/debug.interfaces'; + +/** Request augmented with the per-request timeline + debug id. */ +export interface IDebuggableRequest extends Request { + debugId?: string; + timeline?: ReturnType; +} + +/** + * Captures every request/response exchange, builds a performance timeline and + * enhances any error before storing the record for later inspection/replay. + * + * Designed to be mounted only in non-production environments (wired up by the + * module) so it never adds overhead to real traffic. + */ +@Injectable() +export class DebugCaptureMiddleware implements NestMiddleware { + private readonly logger = new Logger(DebugCaptureMiddleware.name); + + constructor( + private readonly capture: RequestCaptureService, + private readonly timelines: PerformanceTimelineService, + private readonly stackTraces: StackTraceService, + ) {} + + use(req: IDebuggableRequest, res: Response, next: NextFunction): void { + const debugId = randomUUID(); + const timeline = this.timelines.create(debugId); + + // Expose to downstream layers so controllers/services can add spans. + req.debugId = debugId; + req.timeline = timeline; + res.setHeader('x-debug-id', debugId); + + const correlationId = + (req.headers['x-correlation-id'] as string | undefined) ?? + (req.headers['x-request-id'] as string | undefined); + + // Buffer the response body by patching res.send/json without breaking the + // normal response flow. + let responseBody: unknown; + const originalJson = res.json.bind(res); + const originalSend = res.send.bind(res); + (res as any).json = (body: unknown) => { + responseBody = body; + return originalJson(body); + }; + (res as any).send = (body: unknown) => { + if (responseBody === undefined) responseBody = body; + return originalSend(body); + }; + + const finalize = (error?: unknown) => { + // Guard against double invocation (finish + error can both fire). + if (res.locals.__debugRecorded) return; + res.locals.__debugRecorded = true; + + const response: ICapturedResponse = { + statusCode: res.statusCode, + headers: this.flattenHeaders(res.getHeaders()), + body: responseBody, + }; + + const record: IDebugRecord = { + id: debugId, + correlationId, + timestamp: new Date().toISOString(), + request: { + method: req.method, + url: req.originalUrl, + path: req.path, + query: { ...req.query }, + headers: this.flattenHeaders(req.headers), + body: req.body, + ip: req.ip, + }, + response, + timeline: timeline.build(), + error: error ? this.stackTraces.enhance(error) : undefined, + }; + + try { + this.capture.store(record); + } catch (storeErr) { + this.logger.warn(`Failed to store debug record: ${String(storeErr)}`); + } + }; + + res.on('finish', () => finalize()); + res.on('error', (err) => finalize(err)); + + next(); + } + + private flattenHeaders( + headers: Record | NodeJS.Dict, + ): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) continue; + out[key] = Array.isArray(value) ? value.join(', ') : String(value); + } + return out; + } +} diff --git a/src/debugging/services/performance-timeline.service.ts b/src/debugging/services/performance-timeline.service.ts new file mode 100644 index 0000000..b31bf33 --- /dev/null +++ b/src/debugging/services/performance-timeline.service.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@nestjs/common'; +import { + IPerformanceTimeline, + ITimelineSpan, +} from '../interfaces/debug.interfaces'; + +/** + * A mutable timeline builder scoped to a single request. The middleware creates + * one of these per request and hands it down so any layer can record spans. + */ +export class TimelineRecorder { + private readonly spans: ITimelineSpan[] = []; + private readonly startedAt = process.hrtime.bigint(); + + constructor(public readonly requestId: string) {} + + /** Record a span given an absolute high-resolution start marker. */ + record(name: string, startMark: bigint, metadata?: Record): void { + const now = process.hrtime.bigint(); + this.spans.push({ + name, + startOffsetMs: this.toMs(startMark - this.startedAt), + durationMs: this.toMs(now - startMark), + metadata, + }); + } + + /** + * Time an arbitrary (optionally async) operation and record it as a span. + * Returns the operation's result so it can be used transparently. + */ + async measure(name: string, fn: () => Promise | T): Promise { + const start = process.hrtime.bigint(); + try { + return await fn(); + } finally { + this.record(name, start); + } + } + + /** Marker helper so callers don't need to import process.hrtime directly. */ + mark(): bigint { + return process.hrtime.bigint(); + } + + /** Finalise into an immutable timeline snapshot. */ + build(): IPerformanceTimeline { + const total = this.toMs(process.hrtime.bigint() - this.startedAt); + return { + requestId: this.requestId, + totalDurationMs: total, + // Sort by start offset so the timeline reads chronologically. + spans: [...this.spans].sort((a, b) => a.startOffsetMs - b.startOffsetMs), + }; + } + + private toMs(nanos: bigint): number { + return Math.round(Number(nanos) / 1e3) / 1e3; // nanoseconds → ms, 3dp + } +} + +/** + * Factory + helper service for performance timelines. Kept as an injectable so + * it can be mocked in controllers/tests and so future config (e.g. sampling) + * has a home. + */ +@Injectable() +export class PerformanceTimelineService { + create(requestId: string): TimelineRecorder { + return new TimelineRecorder(requestId); + } + + /** + * Identify spans that dominate the request, useful for surfacing the slowest + * phase in the inspector UI. Returns spans sorted by duration descending. + */ + hotspots(timeline: IPerformanceTimeline, limit = 3): ITimelineSpan[] { + return [...timeline.spans] + .sort((a, b) => b.durationMs - a.durationMs) + .slice(0, limit); + } +} diff --git a/src/debugging/services/request-capture.service.ts b/src/debugging/services/request-capture.service.ts new file mode 100644 index 0000000..d309f06 --- /dev/null +++ b/src/debugging/services/request-capture.service.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@nestjs/common'; +import { IDebugRecord } from '../interfaces/debug.interfaces'; + +/** + * Configuration for the in-memory capture buffer. Capacity is bounded so the + * debugger never grows unbounded in a long-running dev process. + */ +export interface CaptureConfig { + /** Maximum number of records retained in the ring buffer. */ + capacity: number; + /** Bodies larger than this (stringified length) are truncated. */ + maxBodyBytes: number; + /** Header names that are redacted before storage. */ + redactedHeaders: string[]; +} + +const DEFAULT_CONFIG: CaptureConfig = { + capacity: 200, + maxBodyBytes: 64 * 1024, + redactedHeaders: ['authorization', 'cookie', 'set-cookie', 'x-api-key'], +}; + +/** + * Stores captured request/response exchanges in a bounded in-memory ring + * buffer. This is the backing store for the inspection and replay endpoints. + * + * Intentionally process-local and ephemeral: it is a developer aid, not an + * audit log, so nothing is persisted. + */ +@Injectable() +export class RequestCaptureService { + private readonly config: CaptureConfig; + private readonly buffer: IDebugRecord[] = []; + + constructor(config?: Partial) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + /** Store a record, evicting the oldest entry when at capacity. */ + store(record: IDebugRecord): void { + this.buffer.push(this.sanitise(record)); + while (this.buffer.length > this.config.capacity) { + this.buffer.shift(); + } + } + + /** Return records newest-first, optionally limited. */ + list(limit?: number): IDebugRecord[] { + const newestFirst = [...this.buffer].reverse(); + return typeof limit === 'number' ? newestFirst.slice(0, limit) : newestFirst; + } + + /** Retrieve a single record by id. */ + get(id: string): IDebugRecord | undefined { + return this.buffer.find((r) => r.id === id); + } + + /** Drop everything currently buffered. */ + clear(): void { + this.buffer.length = 0; + } + + get size(): number { + return this.buffer.length; + } + + /** Redact sensitive headers and truncate oversized bodies before storage. */ + private sanitise(record: IDebugRecord): IDebugRecord { + const redactHeaders = (headers: Record) => { + const out: Record = {}; + for (const [key, value] of Object.entries(headers)) { + out[key] = this.config.redactedHeaders.includes(key.toLowerCase()) + ? '[REDACTED]' + : value; + } + return out; + }; + + const truncateBody = (body: unknown): unknown => { + if (body === undefined || body === null) return body; + const serialised = typeof body === 'string' ? body : JSON.stringify(body); + if (serialised.length <= this.config.maxBodyBytes) return body; + return `[TRUNCATED ${serialised.length} bytes]`; + }; + + return { + ...record, + request: { + ...record.request, + headers: redactHeaders(record.request.headers), + body: truncateBody(record.request.body), + }, + response: record.response + ? { + ...record.response, + headers: redactHeaders(record.response.headers), + body: truncateBody(record.response.body), + } + : undefined, + }; + } +} diff --git a/src/debugging/services/request-replay.service.ts b/src/debugging/services/request-replay.service.ts new file mode 100644 index 0000000..561fa3b --- /dev/null +++ b/src/debugging/services/request-replay.service.ts @@ -0,0 +1,126 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { RequestCaptureService } from './request-capture.service'; +import { IReplayOptions, IReplayResult } from '../interfaces/debug.interfaces'; + +/** + * Re-issues a previously captured request against the running service (or any + * supplied base URL) so a developer can reproduce a bug deterministically and + * compare the new response to the original. + */ +@Injectable() +export class RequestReplayService { + private readonly logger = new Logger(RequestReplayService.name); + + /** Default target — the locally running instance. */ + private readonly selfBaseUrl = + process.env.DEBUG_REPLAY_BASE_URL ?? + `http://127.0.0.1:${process.env.PORT ?? 3000}`; + + // Headers that must never be replayed verbatim because they describe the + // original transport, not the logical request. + private static readonly STRIPPED_HEADERS = [ + 'host', + 'content-length', + 'connection', + 'accept-encoding', + ]; + + constructor(private readonly capture: RequestCaptureService) {} + + /** + * Replay the captured record identified by `id`. + * @throws NotFoundException when the record is no longer buffered. + */ + async replay(id: string, options: IReplayOptions = {}): Promise { + const record = this.capture.get(id); + if (!record) { + throw new NotFoundException(`No captured request with id "${id}"`); + } + + const baseUrl = options.baseUrl ?? this.selfBaseUrl; + const target = `${baseUrl.replace(/\/$/, '')}${record.request.path}`; + const url = new URL(target); + for (const [key, value] of Object.entries(record.request.query ?? {})) { + url.searchParams.set(key, String(value)); + } + + const headers = this.buildHeaders(record.request.headers, options.headerOverrides); + const body = options.bodyOverride ?? record.request.body; + const method = record.request.method.toUpperCase(); + const hasBody = body !== undefined && body !== null && method !== 'GET' && method !== 'HEAD'; + + const start = process.hrtime.bigint(); + const response = await fetch(url.toString(), { + method, + headers, + body: hasBody ? this.serialiseBody(body, headers) : undefined, + }); + const durationMs = + Math.round(Number(process.hrtime.bigint() - start) / 1e3) / 1e3; + + const responseBody = await this.readBody(response); + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => (responseHeaders[key] = value)); + + this.logger.debug( + `Replayed ${method} ${record.request.path} → ${response.status} in ${durationMs}ms`, + ); + + const originalStatus = record.response?.statusCode; + return { + sourceId: id, + target: url.toString(), + statusCode: response.status, + durationMs, + headers: responseHeaders, + body: responseBody, + diff: { + replayedStatus: response.status, + originalStatus, + statusChanged: + originalStatus !== undefined && originalStatus !== response.status, + }, + }; + } + + private buildHeaders( + captured: Record, + overrides?: Record, + ): Record { + const headers: Record = {}; + for (const [key, value] of Object.entries(captured)) { + if (RequestReplayService.STRIPPED_HEADERS.includes(key.toLowerCase())) { + continue; + } + // Redacted values cannot be replayed; drop them so the caller can supply + // a fresh credential via overrides. + if (value === '[REDACTED]') continue; + headers[key] = value; + } + return { ...headers, ...overrides }; + } + + private serialiseBody(body: unknown, headers: Record): string { + if (typeof body === 'string') return body; + const contentType = Object.entries(headers).find( + ([k]) => k.toLowerCase() === 'content-type', + )?.[1]; + if (!contentType || contentType.includes('application/json')) { + headers['content-type'] = headers['content-type'] ?? 'application/json'; + return JSON.stringify(body); + } + return String(body); + } + + private async readBody(response: Response): Promise { + const contentType = response.headers.get('content-type') ?? ''; + if (contentType.includes('application/json')) { + try { + return await response.json(); + } catch { + return undefined; + } + } + return response.text(); + } +} diff --git a/src/debugging/services/stack-trace.service.ts b/src/debugging/services/stack-trace.service.ts new file mode 100644 index 0000000..8c5c9c6 --- /dev/null +++ b/src/debugging/services/stack-trace.service.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@nestjs/common'; +import { IEnhancedStackTrace, IStackFrame } from '../interfaces/debug.interfaces'; + +/** + * Parses raw V8 stack strings into structured frames and flags which frames + * belong to the application versus third-party / runtime code. This makes + * error responses far easier to triage than a flat string. + */ +@Injectable() +export class StackTraceService { + // Matches the two common V8 frame shapes: + // "at fnName (/path/file.ts:12:34)" + // "at /path/file.ts:12:34" + private static readonly FRAME_WITH_FN = + /^\s*at\s+(.+?)\s+\((.*?):(\d+):(\d+)\)$/; + private static readonly FRAME_BARE = /^\s*at\s+(.*?):(\d+):(\d+)$/; + + /** + * Build an enhanced trace from any thrown value. Follows the `cause` chain + * so wrapped errors keep their original origin information. + */ + enhance(error: unknown): IEnhancedStackTrace { + if (!(error instanceof Error)) { + return { + name: 'NonError', + message: typeof error === 'string' ? error : JSON.stringify(error), + frames: [], + }; + } + + const frames = this.parseFrames(error.stack ?? ''); + const origin = frames.find((f) => f.isApplicationCode); + + const enhanced: IEnhancedStackTrace = { + name: error.name, + message: error.message, + frames, + origin, + raw: error.stack, + }; + + const cause = (error as Error & { cause?: unknown }).cause; + if (cause !== undefined && cause !== null) { + enhanced.cause = this.enhance(cause); + } + + return enhanced; + } + + /** Parse the lines of a raw stack string into structured frames. */ + parseFrames(stack: string): IStackFrame[] { + return stack + .split('\n') + .map((line) => this.parseLine(line)) + .filter((frame): frame is IStackFrame => frame !== null); + } + + private parseLine(line: string): IStackFrame | null { + let match = StackTraceService.FRAME_WITH_FN.exec(line); + if (match) { + return this.toFrame(match[1], match[2], Number(match[3]), Number(match[4])); + } + + match = StackTraceService.FRAME_BARE.exec(line); + if (match) { + return this.toFrame('', match[1], Number(match[2]), Number(match[3])); + } + + return null; + } + + private toFrame( + functionName: string, + fileName: string, + lineNumber: number, + columnNumber: number, + ): IStackFrame { + const isNodeModule = fileName.includes('node_modules'); + const isInternal = fileName.startsWith('node:') || !fileName.includes('/'); + return { + functionName, + fileName, + lineNumber, + columnNumber, + isNodeModule, + // Application code is anything that is neither a dependency nor a Node + // internal module. + isApplicationCode: !isNodeModule && !isInternal, + }; + } +}