UNPKG

@dudousxd/nestjs-telescope

Version:

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

364 lines 19.9 kB
import type { CanActivate, DynamicModule, Type } from '@nestjs/common'; import type { TelescopeAiOptions } from '../ai/diagnoser.js'; import type { AlertsOptions } from '../alerts/alert-rule.js'; import type { DashboardAuthOptions } from '../auth/dashboard-auth-config.js'; import type { TelescopeCoreOptions } from '../config/options.js'; import type { TelescopeExtension } from '../extension/types.js'; import type { PulseServiceOptions } from '../pulse/pulse.service.js'; import type { QueueActionRequest, QueueManager } from '../queue/queue-manager.js'; import type { ScheduleManager } from '../schedule/schedule-manager.js'; import type { StorageProvider } from '../storage/storage-provider.js'; import type { Watcher } from './watcher.js'; /** Context handed to the authorizer to decide API/UI access. */ export interface AuthorizerContext { /** The platform request object (Express or Fastify). */ request: unknown; } /** * The minimal structural request the telescope hooks receive — enough for * header/user checks without coupling to Express/Fastify types. A real Express * `Request` or Fastify `FastifyRequest` satisfies this shape, so a host can * pass its platform request straight through with no hand-rolled guard. */ export interface TelescopeHttpRequest { method?: string; url?: string; headers?: Record<string, string | string[] | undefined>; /** Whatever upstream auth middleware attached; shape is the host app's. */ user?: unknown; [key: string]: unknown; } /** * Narrows a raw platform request (typed `unknown` at the framework boundary, * e.g. `@Req() request: unknown`) into a {@link TelescopeHttpRequest} without an * unsafe cast. Every own-enumerable field passes through via the index * signature; `headers` entries that aren't `string | string[] | undefined` are * dropped rather than force-cast. A non-object input yields `{}`. */ export declare function toTelescopeHttpRequest(request: unknown): TelescopeHttpRequest; /** * Tuning for how thrown server-side exceptions become `exception` entries. * * WHY this exists: by default Telescope does NOT record a NestJS `HttpException` * whose status is a 4xx (`>= 400 && < 500`) as an exception entry. A 403 * (Forbidden), 404 (NotFound) or a validation 400 is expected control flow — * the framework doing its job — not an incident. Recording each one as an * exception opens a NEW exception family (the family hash keys on * name+message+top-frame, so each call site is distinct), fires the * `new-exception` Slack alert, and in AI auto-mode spends model tokens * diagnosing intended behaviour. In production every permission denial would * page on-call and burn a diagnosis. (This default changed after exactly that * incident: Telescope's own client-errors `authorize` gate threw a 403, which * was captured as a brand-new family and paged Slack.) * * The 4xx is NOT lost — the request-capture middleware still records the 4xx * `statusCode` on its own `request` entry; only the exception family is skipped. * * 5xx HttpExceptions and non-`HttpException` errors are ALWAYS recorded. */ export interface ExceptionsOptions { /** * When `true`, restore the pre-change behaviour: 4xx `HttpException`s are * captured as exception entries again (and so can group, alert, and be * diagnosed). Default `false` — 4xx control flow is skipped. Set this only if * your host genuinely treats 4xx as exceptions worth grouping/alerting on. */ captureHttp4xx?: boolean; } /** * Public front-end error ingestion (`POST <telescope>/api/client-errors`). When * enabled, browsers report errors directly to Telescope, which records them as * `client_exception` entries through the normal pipeline (family-hash, * `failed`/`client`/`user:<id>` tags, alerts, prune, archive, dashboard). * * DISABLED by default: a public, unauthenticated ingestion surface is opt-in. * While disabled the controller is mounted but returns 404 for every request, so * the route never silently accepts traffic and toggling needs no remount. * * Security knobs, all best-effort and PER-POD (see the multi-replica caveat on * `rateLimit`): a byte cap (`maxBodyBytes`), an in-memory per-IP token bucket * (`rateLimit`), and an `authorize` hook for session/header validation. */ export interface ClientErrorsOptions { /** Master switch. Default `false` — the endpoint 404s until explicitly enabled. */ enabled: boolean; /** * Hard cap on the accepted request body size in bytes. A larger body is * rejected (413) BEFORE structural validation, so a hostile browser can't make * Telescope parse a huge payload. Default `32_768` (32 KB). */ maxBodyBytes?: number; /** * Per-IP token-bucket rate limit. `perMinute` requests are allowed per IP per * minute (default `60`); over the limit returns 429. The bucket map is bounded * and per-pod (in-memory), so in a multi-replica deployment the EFFECTIVE limit * is `perMinute × pods` and a client pinned to one pod sees exactly `perMinute` * — acceptable for abuse-dampening, not a hard quota. A shared limiter would * need a cross-pod store and is out of scope here. */ rateLimit?: { perMinute: number; }; /** * Optional gate that runs FIRST, before validation/rate-limiting. Return * `false` to reject with 403 — lets a host require a session cookie or a shared * header on the public endpoint. A throw is treated as a denial (fail closed) * and never crashes the request. */ authorize?: (request: TelescopeHttpRequest) => boolean | Promise<boolean>; } /** * Tuning for the pre-record request-body capture gate (see * {@link TelescopeModuleOptions.requestCapture}). */ export interface RequestCaptureOptions { /** * Bodies larger than this are not captured — the request entry still records * method/path/status/duration, only `payload` becomes * `'[Skipped: N bytes > maxBodyBytes]'`. Size comes from the `content-length` * header when present; otherwise, for a string/Buffer body, its own length — * NEVER from `JSON.stringify`-ing a parsed body (that IS the synchronous walk * this gate exists to avoid), so a parsed object body without a * `content-length` header passes the size gate untouched. Default `131_072` * (128 KiB). Set `false` to disable the size gate entirely. * * This is the safe-by-default fix for event-loop stalls from giant bodies: the * gate runs in the middleware BEFORE `TelescopeService.record()`, so the * synchronous redaction walk never even sees a skipped body. */ maxBodyBytes?: number | false; /** * Content types whose bodies are never captured — matched against the * request's `content-type` header. A `string` pattern matches as a * case-insensitive PREFIX (e.g. `'multipart/form-data'` matches * `'multipart/form-data; boundary=...'`); a `RegExp` is `.test()`-ed against * the raw header value. Payload becomes `'[Skipped: <content-type>]'`. * Default: `['application/offset+octet-stream', 'application/octet-stream', * 'multipart/form-data']` (binary/upload bodies). */ skipBodyContentTypes?: (string | RegExp)[]; /** * Skip body capture entirely for matching requests (e.g. an upload route) — * checked in addition to (not instead of) the content-type/size gates. The * request entry is still recorded (method/path/status/duration/user/headers); * only `payload` becomes `'[Skipped: skipBody predicate]'`. Runs synchronously * and is never awaited — return a plain `boolean`, not a `Promise`. */ skipBody?: (request: TelescopeHttpRequest) => boolean; } export interface TelescopeModuleOptions extends TelescopeCoreOptions { /** Storage provider. Defaults to a SqliteStorageProvider(':memory:'). */ storage?: StorageProvider; /** Watchers to register. Empty in the host plan. */ watchers?: Watcher[]; /** Extensions contributing watchers, entry types, dashboards, and data providers. */ extensions?: TelescopeExtension[]; /** Live-queue managers (e.g. BullMqQueueManager). Each contributes a driver to /queues/live. * Watchers in `watchers` implementing the `QueueManager` SPI are auto-registered — this array is * only needed for standalone managers (see the SPI doc in `queue/queue-manager.ts`). */ queueManagers?: QueueManager[]; /** * Schedule managers (e.g. the `@nestjs/schedule` watcher). Each contributes * registered cron/interval/timeout tasks to /schedules/live. * * Watchers in `watchers` that implement the `ScheduleManager` SPI are * auto-registered — this array is only needed for standalone managers (see * the SPI doc in `schedule/schedule-manager.ts`). */ scheduleManagers?: ScheduleManager[]; /** * Authorizes API access. Default: allow when NODE_ENV !== 'production', * deny otherwise (until the host supplies one). */ authorizer?: (ctx: AuthorizerContext) => boolean | Promise<boolean>; /** * Authorizes a queue MUTATION (retry/remove/promote/retry-all/redrive). * Separate from `authorizer` (reads). DEFAULT: deny — every mutation is 403 * until the host supplies this. Throwing denies (fails closed). */ authorizeAction?: (ctx: AuthorizerContext, action: QueueActionRequest) => boolean | Promise<boolean>; /** * Whether TelescopeModule auto-registers the request-capture middleware via * NestJS `configure()`. Default `true`. Set `false` when the host app uses * `setGlobalPrefix(...)`: NestJS scopes module middleware to the prefixed * route table, so the catch-all only captures `/`. In that case register the * capture globally in bootstrap instead — * `app.use(telescopeRequestCapture(app.get(TelescopeService)))`. */ registerRequestMiddleware?: boolean; /** * Pre-record capture gate for request bodies: size cap, content-type skip * list, and a route predicate — all evaluated in the middleware BEFORE * `TelescopeService.record()`, so a giant/binary body never reaches the * synchronous redaction walk. The request entry (method/path/status/duration/ * user/headers) is always recorded; only `payload` is replaced by a marker * string when a gate trips. ON by default (128 KiB cap + a binary * content-type list) — this is the safe default, not an opt-in. See * {@link RequestCaptureOptions}. */ requestCapture?: RequestCaptureOptions; /** * Resolves the "authenticated user" recorded on a request entry from the raw * platform request. Defaults to reading `request.user` (the common * Passport/guard convention). Return `null`/`undefined` for anonymous. The * resolved value is redacted by the Recorder like any other content. */ resolveUser?: (request: unknown) => unknown; /** * Host-provided hook that runs an engine `EXPLAIN` for a captured query and * returns the plan. Telescope is DB-agnostic, so the HOST brings its own * connection/dialect — Telescope only hands over the captured SQL and bindings * exactly as recorded. When unset, the explain endpoint reports 404 (feature * off) and `meta.explainEnabled` is `false`. * * The hook runs ARBITRARY SQL `EXPLAIN` against your database, so scope its * connection read-only (and to non-sensitive schemas) — a captured statement * is replayed as `EXPLAIN <sql>`. Throwing surfaces as a clean `{ message }` * error to the dashboard (the plan failed to run), not a crash. * * @example MySQL (mysql2): * ```ts * explainQuery: async (sql, bindings) => { * const [rows] = await pool.query(`EXPLAIN FORMAT=JSON ${sql}`, bindings); * return rows; * } * ``` */ explainQuery?: (sql: string, bindings: unknown[]) => Promise<unknown>; /** * Cookie-session gate for the dashboard. When set, every guarded `/api/*` * route (except `/api/auth/*`) requires a valid signed session cookie, AND * the existing `authorizer` still runs (AND semantics). The cookie is minted * by either mode (`session` host-auth bridge / built-in `login`). When unset, * gate behavior is unchanged (`authorizer` / NODE_ENV default). A configured * `dashboardAuth` with a missing/empty `secret` or no hook is a boot error. */ dashboardAuth?: DashboardAuthOptions; /** * Pluggable-channel alerting. When set, Telescope evaluates `rules` and fans * each fired alert out to every configured `channels` entry (Slack / raw * webhook / custom sink) concurrently; one channel failing never blocks the * others. The legacy single `webhookUrl` is still accepted and folded into the * channels. A configured `alerts` with NO destination or empty `rules` is a * fail-closed boot error. See {@link AlertsOptions}. */ alerts?: AlertsOptions; /** * Tuning for the Pulse health snapshot (`/api/pulse` + the Overview). Most * hosts never set this. The notable knob is `slowRouteMs`: the p99 (ms) a * route must reach to count as a "Slow request hotspot" (default 1000, matching * the `slow` request-tag threshold). Without it, hotspots are a pure top-N p99 * ranking, so a quiet host surfaces fast routes (e.g. `/health`) as false * alarms. See {@link PulseServiceOptions}. */ pulse?: PulseServiceOptions; /** * Public front-end error ingestion. When `enabled`, browsers can POST errors * to `<telescope>/api/client-errors` and they are recorded as `client_exception` * entries. DISABLED by default. See {@link ClientErrorsOptions}. */ clientErrors?: ClientErrorsOptions; /** * AI-powered exception diagnosis. Supply a `diagnoser` (e.g. * `createAiSdkDiagnoser` from `@dudousxd/nestjs-telescope-ai`) and the dashboard * exposes a "Diagnose with AI" button on exception detail pages * (`POST <telescope>/api/exceptions/:id/diagnose`). In `mode: 'auto'`, a NEW * exception family is ALSO diagnosed fire-and-forget on the flush path and the * result is attached to a firing `new-exception` alert when ready. The SHAPE is * defined in core so core carries no AI dependency. See {@link TelescopeAiOptions}. */ ai?: TelescopeAiOptions; /** * How thrown server-side exceptions become `exception` entries. The notable * knob is `captureHttp4xx`: by default 4xx `HttpException`s (Forbidden / * NotFound / validation 400) are treated as control flow and NOT recorded as * exceptions, so they never open a family, fire `new-exception`, or trigger AI * diagnosis. See {@link ExceptionsOptions}. */ exceptions?: ExceptionsOptions; /** * MCP (Model Context Protocol) server. When enabled, Telescope serves a * stateless JSON-RPC MCP endpoint at `POST <telescope>/api/mcp` so coding * agents (Claude Code, Cursor, …) can query the captured data directly — * "why is POST /checkout slow?" → the agent pulls the batch waterfall with * every query. Backed by the same storage/stats APIs as the dashboard. * * AUTH: when a `token` is configured, every MCP request MUST carry a * `Authorization: Bearer <token>` header (the MCP transport's auth model; the * cookie-session dashboard gate doesn't apply to a header-only agent client). * Without a token the endpoint is allowed ONLY when `NODE_ENV !== 'production'` * (mirroring the default-open-in-dev dashboard authorizer) — in production a * tokenless MCP config is refused (403) so the surface never opens unguarded. * * Pass `true` for the dev-only default, or `{ token }` to require a Bearer * token. DISABLED by default (`undefined`). */ mcp?: boolean | { token?: string; }; /** * Overhead guard / overload protection. Telescope watches the event-loop lag * (via `perf_hooks.monitorEventLoopDelay`) and, when the p99 lag crosses a * threshold, PAUSES capture (the Recorder drops new `record()` calls) until * the lag recovers — so a telescope under load can never amplify an incident. * * Pass `true` (the default) for the 200ms threshold, `false` to disable, or * `{ maxEventLoopLagMs }` to tune it. ON by default at 200ms. * * `startupGraceMs` (default ~5000) is a window after the guard arms during * which it samples but never pauses/logs — so the synchronous bootstrap stall * (DI wiring, migrations, codegen blocking the event loop) can't trip the guard * on a transient. Set `0` to arm immediately. Ignored when protection is off. */ overloadProtection?: boolean | { maxEventLoopLagMs?: number; startupGraceMs?: number; }; /** * Guard classes (or already-instantiated `CanActivate`s) fronting the console's * API controllers — `TelescopeController` (entries/metrics/traces/ext data * providers/retention/…) and `StreamController`'s live SSE feed. Stamped * ALONGSIDE Telescope's own built-in gate (`TelescopeGuard`'s `authorizer` / * `dashboardAuth` / dev-open-prod-closed default) — APPEND, not replace: a * request must pass the built-in gate AND every guard listed here. * * This is deliberate and differs from a from-scratch dashboard: Telescope's * console controllers already ship a default-deny-in-production gate, so * `guards` is an ADDITIONAL seam for hosts that want to front the console with * THEIR OWN auth — e.g. reusing the app's existing cookie-session guard — * instead of (or in addition to) configuring `dashboardAuth`'s own * login/session bridge. See the "Securing the console" guide. * * IMPORTANT: pass the SAME `guards` (and matching `imports`) to * `TelescopeUiModule.forRoot({ guards, imports })` too — the dashboard's page * (HTML shell + hashed assets) lives in a SEPARATE package/module with no * visibility into this option, so a `guards` set here alone still leaves the * page itself reachable by an anonymous full-page navigation. * * A class guard's own DEPENDENCIES resolve from this module's `imports` (see * {@link imports}) — `TelescopeModule` has no application context of its own * to pull them from otherwise. An already-instantiated guard (a `CanActivate` * object, not a class) needs no `imports` entry. */ guards?: Array<Type<CanActivate> | CanActivate>; /** * Extra `imports` merged into `TelescopeModule`'s own dynamic module — the DI * resolution path for a class passed to {@link guards} (or any other provider * the console controllers need reachable). Typically the host's own auth * module, e.g. `imports: [AuthModule]` alongside `guards: [ConsoleAuthGuard]`. */ imports?: DynamicModule['imports']; } export interface TelescopeOptionsFactory { createTelescopeOptions(): Promise<TelescopeModuleOptions> | TelescopeModuleOptions; } export declare const TELESCOPE_OPTIONS: unique symbol; export declare const TELESCOPE_STORAGE: unique symbol; export declare const TELESCOPE_CONFIG: unique symbol; /** Resolved `dashboardAuth` config (or `null` when unconfigured). Boot-validated. */ export declare const TELESCOPE_DASHBOARD_AUTH: unique symbol; /** Kept for future DI use; the registry reads `options.queueManagers` directly. */ export declare const QUEUE_MANAGERS: unique symbol; /** Resolved ExtensionRegistry (built + boot-validated from options.extensions). */ export declare const TELESCOPE_EXTENSIONS: unique symbol; //# sourceMappingURL=telescope.options.d.ts.map