Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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: [],
Expand Down
62 changes: 62 additions & 0 deletions src/debugging/README.md
Original file line number Diff line number Diff line change
@@ -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 {}
```
111 changes: 111 additions & 0 deletions src/debugging/debug.controller.ts
Original file line number Diff line number Diff line change
@@ -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' };
}
}
53 changes: 53 additions & 0 deletions src/debugging/debugging.module.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
27 changes: 27 additions & 0 deletions src/debugging/dto/replay-request.dto.ts
Original file line number Diff line number Diff line change
@@ -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 <fresh-token>' },
})
@IsOptional()
@IsObject()
headerOverrides?: Record<string, string>;

@ApiPropertyOptional({
description: 'Replacement request body. When omitted the captured body is reused.',
})
@IsOptional()
bodyOverride?: unknown;
}
113 changes: 113 additions & 0 deletions src/debugging/interfaces/debug.interfaces.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

/** 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<string, unknown>;
headers: Record<string, string>;
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<string, string>;
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<string, string>;
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<string, string>;
/** Replacement body; when omitted the captured body is reused. */
bodyOverride?: unknown;
}
Loading
Loading