@dudousxd/nestjs-telescope
Version:
Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.
260 lines • 12.3 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
// packages/core/src/nest/telescope-request.middleware.ts
import { Inject, Injectable, Optional } from '@nestjs/common';
import { EntryType } from '../entry/entry.js';
import { ProfilerService } from '../profiling/profiler.service.js';
import { normalizeRoute } from '../query/normalize-route.js';
import { normalizeRequest } from './platform-request.js';
import { TELESCOPE_OPTIONS, toTelescopeHttpRequest, } from './telescope.options.js';
import { TelescopeService } from './telescope.service.js';
/**
* Default `requestCapture.maxBodyBytes`: bodies over 128 KiB are not captured.
* ON by default — this is the safe-by-default fix for a synchronous redaction
* walk over a giant decoded body stalling the event loop.
*/
const DEFAULT_MAX_BODY_BYTES = 131_072;
/**
* Default `requestCapture.skipBodyContentTypes`: binary/streamed upload
* bodies whose captured "payload" is never useful and can be arbitrarily
* large (e.g. a tus resumable-upload PATCH chunk).
*/
const DEFAULT_SKIP_BODY_CONTENT_TYPES = [
'application/offset+octet-stream',
'application/octet-stream',
'multipart/form-data',
];
function resolveRequestCapture(options) {
return {
maxBodyBytes: options?.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES,
skipBodyContentTypes: options?.skipBodyContentTypes ?? DEFAULT_SKIP_BODY_CONTENT_TYPES,
skipBody: options?.skipBody,
};
}
/**
* The dashboard UI, its API and assets are all mounted under the configured
* path prefix; skip them to avoid self-capture of the dashboard's own polling.
*/
function isTelescopePath(url, prefix) {
const queryStart = url.indexOf('?');
const path = queryStart === -1 ? url : url.slice(0, queryStart);
return path === prefix || path.startsWith(`${prefix}/`);
}
function asFinishable(res) {
const r = res;
return typeof r?.once === 'function' ? res : null;
}
function isRecord(value) {
return typeof value === 'object' && value !== null;
}
/** Whether the request was re-issued by Telescope's replay endpoint. */
function hasReplayHeader(headers) {
const value = headers['x-telescope-replay'];
if (typeof value === 'string')
return value !== '';
return Array.isArray(value) && value.length > 0;
}
/** The parsed request body (Express/Fastify), or `null` when none is present. */
function readPayload(request) {
return isRecord(request) && 'body' in request ? request.body : null;
}
/** First string value of a (possibly multi-value) header, case-sensitive on `name`. */
function headerValue(headers, name) {
const value = headers[name];
if (typeof value === 'string')
return value;
if (Array.isArray(value) && typeof value[0] === 'string')
return value[0];
return undefined;
}
/** Parsed `content-length` header, or `undefined` when absent/non-numeric. */
function contentLengthOf(headers) {
const raw = headerValue(headers, 'content-length');
if (raw === undefined)
return undefined;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
}
/**
* Whether `contentType` matches any `skipBodyContentTypes` pattern: a `string`
* pattern matches as a case-insensitive PREFIX (so `'multipart/form-data'`
* matches a value carrying a `; boundary=...` suffix); a `RegExp` is
* `.test()`-ed against the raw header value.
*/
function matchesContentType(contentType, patterns) {
const lowerContentType = contentType.toLowerCase();
return patterns.some((pattern) => typeof pattern === 'string'
? lowerContentType.startsWith(pattern.toLowerCase())
: pattern.test(contentType));
}
/**
* O(1) byte-size estimate for the size gate: the `content-length` header when
* present, else a string/Buffer/TypedArray body's own length. Deliberately
* NEVER `JSON.stringify`s a parsed body to measure it — that IS the
* synchronous walk this gate exists to avoid — so a parsed-object body
* without `content-length` returns `undefined` and the size gate is skipped
* for it (falls through to the content-type/skipBody gates instead).
*/
function estimateBodyBytes(body, contentLength) {
if (contentLength !== undefined)
return contentLength;
if (typeof body === 'string')
return Buffer.byteLength(body);
if (Buffer.isBuffer(body))
return body.byteLength;
if (ArrayBuffer.isView(body))
return body.byteLength;
return undefined;
}
/**
* Applies the `requestCapture` gates (content-type → size → predicate) to the
* raw request body BEFORE it ever reaches `TelescopeService.record()` — the
* whole point being that the synchronous redaction walk never sees a skipped
* body. Runs synchronously; a matched gate replaces the body with a marker
* string. Every other request-entry field (method/path/status/duration/user/
* headers) is captured exactly as before regardless of this outcome.
*/
function gateRequestPayload(req, request, capture) {
const body = readPayload(req);
const contentType = headerValue(request.headers, 'content-type');
if (contentType !== undefined && matchesContentType(contentType, capture.skipBodyContentTypes)) {
return `[Skipped: ${contentType}]`;
}
if (capture.maxBodyBytes !== false) {
const estimatedBytes = estimateBodyBytes(body, contentLengthOf(request.headers));
if (estimatedBytes !== undefined && estimatedBytes > capture.maxBodyBytes) {
return `[Skipped: ${estimatedBytes} bytes > ${capture.maxBodyBytes} bytes]`;
}
}
if (capture.skipBody?.(toTelescopeHttpRequest(req))) {
return '[Skipped: skipBody predicate]';
}
return body;
}
/**
* Resolve the authenticated user for a request. Prefers the host's
* `resolveUser` hook; otherwise reads `request.user` (the Passport/guard
* convention). Never throws — a faulty hook or missing user yields `null`.
*/
function readUser(request, resolveUser) {
if (resolveUser !== undefined) {
try {
return resolveUser(request) ?? null;
}
catch {
return null;
}
}
return isRecord(request) && 'user' in request ? (request.user ?? null) : null;
}
let TelescopeRequestMiddleware = class TelescopeRequestMiddleware {
service;
profiler;
/** Resolved once at construction — the per-request hot path never rebuilds this. */
captureConfig;
constructor(service, profiler = undefined, moduleOptions = undefined) {
this.service = service;
this.profiler = profiler;
this.captureConfig = resolveRequestCapture(moduleOptions?.requestCapture);
}
use(req, res, next) {
const request = normalizeRequest(req);
// Skip telescope's own routes (dashboard, API, assets) before any batch/recording work.
// .exclude() can't do this reliably under a global prefix, so we gate in-middleware instead.
if (isTelescopePath(request.url, `/${this.service.path}`)) {
next();
return;
}
// Open the request batch for the whole downstream async execution.
this.service.beginBatch('http');
const startedAt = Date.now();
const response = asFinishable(res);
const route = normalizeRoute(request.method, request.url);
// Opt-in CPU profiling: a single cheap boolean gate when disabled. When a
// request is selected, begin the capture now and stop it on finish; the
// recorded `cpu_profile` entry inherits the active batch/trace context.
let profile = null;
if (this.profiler?.shouldProfile(route)) {
profile = this.profiler.begin(route);
}
// A replay (re-issued from the dashboard) carries `x-telescope-replay: 1`.
// Tag its captured entry so the dashboard/agent can tell replays apart from
// organic traffic (and a host could choose to skip them entirely).
const isReplay = hasReplayHeader(request.headers);
if (response) {
response.once('finish', () => {
this.service.record({
type: EntryType.Request,
// A readable normalized route (e.g. "GET /api/base/:id/mel") groups
// request entries by endpoint via the indexed family_hash column and
// doubles as the human label — no content hydration needed.
familyHash: route,
...(isReplay ? { tags: ['replay'] } : {}),
content: {
method: request.method,
uri: request.url,
headers: request.headers,
// `req.body` is parsed by the host body-parser and `req.user` set by
// guards before this finish callback fires. The body first passes
// through the `requestCapture` gates (content-type/size/predicate) —
// a skipped body never reaches the Recorder's synchronous redaction
// walk. What does get through is still redacted (masking passwords/
// tokens) like any other content.
payload: gateRequestPayload(req, request, this.captureConfig),
user: readUser(req, this.service.resolveUser),
ip: request.ip,
statusCode: response.statusCode,
},
durationMs: Date.now() - startedAt,
});
// Stop + record the profile within the same async context so its
// `cpu_profile` entry shares this request's batchId/traceId. Fire-and-
// forget: `end` never throws and never blocks the response.
if (profile !== null) {
void this.profiler?.end(profile, route);
}
});
}
next();
}
};
TelescopeRequestMiddleware = __decorate([
Injectable(),
__param(0, Inject(TelescopeService)),
__param(1, Optional()),
__param(1, Inject(ProfilerService)),
__param(2, Optional()),
__param(2, Inject(TELESCOPE_OPTIONS)),
__metadata("design:paramtypes", [TelescopeService, Object, Object])
], TelescopeRequestMiddleware);
export { TelescopeRequestMiddleware };
/**
* Builds a framework-agnostic `(req, res, next)` request-capture handler for
* hosts that register it globally via `app.use(...)` in their bootstrap —
* required when the app uses `setGlobalPrefix(...)`, which scopes NestJS module
* middleware so the built-in capture would only see `/`. Pair with
* `TelescopeModule.forRoot({ registerRequestMiddleware: false })`.
*
* @example
* const app = await NestFactory.create(AppModule);
* app.use(telescopeRequestCapture(app.get(TelescopeService)));
*/
export function telescopeRequestCapture(service, options) {
// Hosts on this manual path (setGlobalPrefix apps) don't go through module DI,
// so the capture gates are passed here — same shape as the module option:
// `telescopeRequestCapture(service, { requestCapture: { skipBody: ... } })`.
// Omitted → the safe defaults (128 KiB cap + binary content-type list).
const middleware = new TelescopeRequestMiddleware(service, undefined, options);
return (req, res, next) => middleware.use(req, res, next);
}
//# sourceMappingURL=telescope-request.middleware.js.map