UNPKG

@dudousxd/nestjs-telescope

Version:

Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.

214 lines 10.3 kB
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); } }; var ClientErrorController_1; // packages/core/src/nest/client-error.controller.ts import { BadRequestException, Body, Controller, ForbiddenException, HttpCode, HttpException, Inject, Logger, NotFoundException, Post, Req, } from '@nestjs/common'; import { EntryType } from '../entry/entry.js'; import { exceptionFamilyHash } from '../entry/exception-family-hash.js'; import { userIdentityTag } from '../tagging/tagger.js'; import { ClientErrorRateLimiter } from './client-error-rate-limiter.js'; import { validateClientErrorBody } from './client-error-validation.js'; import { TELESCOPE_OPTIONS, toTelescopeHttpRequest, } from './telescope.options.js'; import { TelescopeService } from './telescope.service.js'; /** Default per-IP requests/minute when `rateLimit` is omitted. */ const DEFAULT_RATE_LIMIT_PER_MINUTE = 60; /** Default accepted body size (32 KB) when `maxBodyBytes` is omitted. */ const DEFAULT_MAX_BODY_BYTES = 32_768; /** HTTP 413 isn't a named Nest exception; this thin subclass keeps the status. */ class PayloadTooLargeException extends HttpException { constructor(message) { super(message, 413); } } class TooManyRequestsException extends HttpException { constructor(message) { super(message, 429); } } /** * Public front-end error ingestion. Mounted on a SEPARATE controller from the * gated dashboard API (like {@link TelescopeAuthController}) so it carries NO * `@UseGuards(TelescopeGuard)` — ordinary users' browsers hit it, they have no * dashboard session. Security is instead the per-endpoint knobs in * {@link ClientErrorsOptions}: an opt-in `enabled` flag (404 while off), a body * byte cap, a per-IP token bucket, and an optional `authorize` hook. * * Records every accepted error as a `client_exception` entry through the normal * pipeline so it composes with new-exception alerts, per-type prune/archive, and * the dashboard — with a family-hash mirroring server exceptions and the * `failed` / `client` / `user:<id>` tags. */ let ClientErrorController = ClientErrorController_1 = class ClientErrorController { options; service; logger = new Logger(ClientErrorController_1.name); /** Per-pod, bounded token bucket (lazily built so a disabled endpoint is free). */ rateLimiter = null; /** One warn for an authorize-hook throw, so a flaky hook can't spam logs. */ warnedAuthorize = false; constructor(options, service) { this.options = options; this.service = service; } async ingest(body, request) { const config = this.options.clientErrors; // Disabled (or unconfigured) => the route doesn't exist for this host. We // 404 rather than 403 so a probe can't even tell ingestion is wired. if (config === undefined || config.enabled !== true) { throw new NotFoundException(); } // 1) authorize hook runs FIRST (before any work): a session/header gate. if (config.authorize !== undefined) { const allowed = await this.runAuthorize(config.authorize, request); if (!allowed) throw new ForbiddenException(); } // 2) body byte cap, BEFORE validation, so a huge payload is cheap to reject. const maxBodyBytes = config.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES; if (this.bodyByteSize(body) > maxBodyBytes) { throw new PayloadTooLargeException('Body exceeds the configured size limit'); } // 3) per-IP rate limit (per-pod, best-effort — see the multi-replica caveat). const ip = this.clientIp(request); if (!this.limiter(config).tryConsume(ip ?? 'unknown')) { throw new TooManyRequestsException('Rate limit exceeded'); } // 4) structural validation — never trust the body; no echo on failure. const validation = validateClientErrorBody(body); if (!validation.ok) { throw new BadRequestException(validation.reason); } const content = validation.value; // 5) record through the normal pipeline: family-hash from name+message+top // frame (mirrors server exceptions), and the composing tags. const tags = ['failed', 'client']; const userTag = userIdentityTag(content.user); if (userTag !== null) tags.push(userTag); // Content field ORDER is load-bearing. The Recorder's content-byte budget // (redact.maxContentBytes) walks keys in insertion order and drops every key // after the budget is exhausted. A deep React error boundary yields a // componentStack of many KB, so the short enrichment fields the alert renders // (clientIp/url/userAgent) MUST precede `stack`/`componentStack` — otherwise a // big stack starves them out and the Slack card silently loses its IP (hence // geo), URL and user-agent. `clientIp` is server-derived (never from the body) // and leads, so it survives even the tightest budget. this.service.record({ type: EntryType.ClientException, familyHash: exceptionFamilyHash({ name: content.name ?? '', message: content.message, stack: content.stack, }), tags, content: { clientIp: ip, message: content.message, name: content.name, url: content.url, userAgent: content.userAgent, user: content.user, release: content.release, extra: content.extra, stack: content.stack, componentStack: content.componentStack, }, }); } /** Build (once) the per-pod token bucket from the resolved rate-limit config. */ limiter(config) { if (this.rateLimiter === null) { this.rateLimiter = new ClientErrorRateLimiter({ perMinute: config.rateLimit?.perMinute ?? DEFAULT_RATE_LIMIT_PER_MINUTE, }); } return this.rateLimiter; } /** * Run the host's authorize hook defensively: a throw is a DENIAL (fail closed) * and warn-logged once, so a buggy hook never 500s the public endpoint nor * floods the logs. */ async runAuthorize(authorize, request) { try { return (await authorize(toTelescopeHttpRequest(request))) === true; } catch (error) { if (!this.warnedAuthorize) { this.warnedAuthorize = true; this.logger.warn(`Telescope clientErrors authorize hook threw; treating as denial. ${error instanceof Error ? error.message : String(error)}`); } return false; } } /** * Best-effort serialized byte size of the parsed body. The framework already * parsed JSON by the time we get here, so we re-serialize to measure bytes * (UTF-8) — a tight enough proxy for the wire size to reject oversized * payloads. A non-serializable body counts as 0 (it'll fail validation anyway). */ bodyByteSize(body) { try { return Buffer.byteLength(JSON.stringify(body) ?? '', 'utf8'); } catch { return 0; } } /** * Extract the reporting client's IP: the first hop of `x-forwarded-for` when * present (the original client behind a proxy), else `request.ip` / * `socket.remoteAddress`. Returns `null` when nothing usable is found. */ clientIp(request) { // Read via `Reflect.get` (not `Object.keys`) because a platform request // exposes `headers`/`ip`/`socket` through prototype getters, not own // enumerable keys — enumerating would miss them entirely. const headers = readProp(request, 'headers'); const forwarded = readProp(headers, 'x-forwarded-for'); if (typeof forwarded === 'string' && forwarded.length > 0) { const firstHop = forwarded.split(',')[0]?.trim(); if (firstHop !== undefined && firstHop.length > 0) return firstHop; } const ip = readProp(request, 'ip'); if (typeof ip === 'string' && ip.length > 0) return ip; const remoteAddress = readProp(readProp(request, 'socket'), 'remoteAddress'); return typeof remoteAddress === 'string' ? remoteAddress : null; } }; __decorate([ Post(), HttpCode(204), __param(0, Body()), __param(1, Req()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object, Object]), __metadata("design:returntype", Promise) ], ClientErrorController.prototype, "ingest", null); ClientErrorController = ClientErrorController_1 = __decorate([ Controller('telescope/api/client-errors'), __param(0, Inject(TELESCOPE_OPTIONS)), __param(1, Inject(TelescopeService)), __metadata("design:paramtypes", [Object, TelescopeService]) ], ClientErrorController); export { ClientErrorController }; /** Read a single property from an unknown object (incl. prototype getters), * returning `undefined` for a non-object or missing property — no cast. */ function readProp(value, key) { if (typeof value !== 'object' || value === null) return undefined; return Reflect.get(value, key); } //# sourceMappingURL=client-error.controller.js.map