@dudousxd/nestjs-telescope
Version:
Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.
214 lines • 10.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); }
};
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