UNPKG

@dudousxd/nestjs-telescope

Version:

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

270 lines 12.6 kB
import type { ModuleRef } from '@nestjs/core'; import type { ResolvedCoreConfig } from '../config/options.js'; import type { Entry, RecordInput } from '../entry/entry.js'; import type { Watcher } from '../nest/watcher.js'; /** * The published, versioned extension contract for `@dudousxd/nestjs-telescope`. * * Extensions are objects (usually returned by a factory so they can take options) * registered via `TelescopeModule.forRoot({ extensions: [...] })`. The host runs * their hooks at module init. Hooks are **multi** (every extension runs; results * accumulate). Single-slot hooks are intentionally not part of 0.x — the registry * is shaped to add them when a consumer needs one. * * @remarks Semver 0.x — the shape may change until 1.0. Out-of-repo extensions * should pin a compatible `@dudousxd/nestjs-telescope` peer range. */ export interface TelescopeExtension { /** Unique id — used in conflict/collision errors and for deterministic ordering. */ name: string; /** Contribute watchers. Merged into the existing `forRoot.watchers` list. */ watchers?(ctx: ExtensionContext): Watcher[]; /** Contribute navigable entry types — makes the hard-coded UI ENTRY_TYPES dynamic. */ entryTypes?(ctx: ExtensionContext): ExtensionEntryType[]; /** Contribute declarative dashboard pages (the panel IR). */ dashboards?(ctx: ExtensionContext): DashboardSpec[]; /** Named server-side queries that panels bind to via `{ provider, query }`. */ dataProviders?(ctx: ExtensionContext): DataProvider[]; /** * Observe EVERY recorded input (pre-sampling, complete counts) — drives metrics * export. Fired synchronously on the hot path; keep it cheap. Isolated by the * host: a throw is swallowed and never affects capture. */ observeRecord?(input: RecordInput): void; /** * Observe each just-persisted (post-sampling) batch — drives span/trace export. * Awaited off the host path inside the flush chain; a throw/rejection is * swallowed and never breaks the flush. */ observeFlush?(entries: Entry[]): void | Promise<void>; } /** Read-only context handed to every extension hook, resolved at module init. */ export interface ExtensionContext { /** Resolve host services (e.g. a durable engine/store, or TELESCOPE_STORAGE). */ readonly moduleRef: ModuleRef; readonly config: ResolvedCoreConfig; } /** A navigable entry type contributed by an extension (subset of the UI's EntryTypeDef). */ export interface ExtensionEntryType { /** Backend `type` filter value, e.g. 'durable'. */ id: string; /** Nav label, e.g. 'Workflows'. */ label: string; /** Tailwind `bg-*` dot color for the nav, e.g. 'bg-amber-400'. */ dot: string; } /** Threshold coloring for a numeric panel. `direction` says which way is worse. */ export interface PanelThresholds { warn: number; bad: number; direction: 'up-bad' | 'down-bad'; } /** * A group of panels rendered together with its own column count. * * `cols: 1` is the full-width row: one panel spanning the section. It exists because a section * renders as a fixed `grid-cols-N` with no `colSpan`, so without it the widest panel a dashboard * has — a table with ten or more columns — could only be declared in a 2-column grid, where it got * half the viewport and left a hole beside it while scrolling sideways inside its own card. That is * exactly how `@dudousxd/nestjs-durable-telescope`'s worker table shipped. */ export interface DashboardSection { title?: string; cols?: 1 | 2 | 3 | 4; panels: Panel[]; } /** A declarative dashboard page. */ export interface DashboardSpec { /** Stable route id, e.g. 'durable.workflows'. Globally unique across extensions. */ id: string; /** Nav label, e.g. 'Workflows'. */ label: string; /** Optional nav grouping header. */ navGroup?: string; /** Flat layout (back-compat). Prefer `sections` for hierarchy. */ panels: Panel[]; /** Sectioned layout. When present, the UI renders these instead of `panels`. */ sections?: DashboardSection[]; } /** A bind from a panel to a named server-side provider + an opaque query object. */ export interface DataBinding { /** Provider name, e.g. 'durable.timeseries'. Resolved on the server. */ provider: string; /** Opaque query passed through to the provider's `resolve`. */ query?: Record<string, unknown>; } /** * A deep-link out of a table cell (to the durable dashboard, a telescope trace, etc.). * * @remarks Two hrefs conventions: * - **In-app hash route** — an `href` starting with `#/` (e.g. `'#/traces/{traceId}'`) * is a route inside the Telescope SPA itself. The UI renders it as a plain * anchor; browsers treat a same-document `#`-only href as a same-document * navigation (URL hash update + `hashchange`, no page reload), which the * dashboard's `HashRouter` picks up — the same mechanism the built-in Entries * table and Entry detail page already use for their own trace links. Leave * `external` unset for these. * - **Host-console link** — an absolute path with no `#` (e.g. * `'/durable/runs/{runId}'`) targets a page in the HOST application (the app * embedding/linking to Telescope), not a Telescope route. This is a real * top-level navigation; set `external: true` when it should open in a new tab. * * The one confirmed in-app hash route today is the trace waterfall view: * `#/traces/{traceId}` (`traceId` is the row key to substitute), which renders * `TracePage` — the single-trace waterfall. Bridges that want to deep-link a * table row to "show me this trace" should target that exact shape. */ export interface LinkSpec { /** A URL template with `{key}` placeholders filled from the row, e.g. '/durable/runs/{runId}'. */ href: string; /** When true, open in a new tab. */ external?: boolean; } export interface Column { key: string; label: string; link?: LinkSpec; /** * Turns this column's header into a sort control. Clicking it cycles * ascending → descending → unsorted and re-resolves the panel's provider with * `sort=<key>` + `dir=asc|desc` merged into the query — see * {@link readTableQuery}. * * Sorting is the provider's job, not the browser's: the UI holds one page, so * a client-side sort would order 50 rows out of 50,000 and present the result * as "the top of the list". Only mark a column sortable when the provider * actually honours `sort`; the header otherwise looks like a control that * silently does nothing. */ sortable?: boolean; /** * Gives this column a filter box in the header. The typed text is committed on * Enter (or blur) and re-resolves the provider with `filter.<key>=<text>` — * see {@link TABLE_FILTER_PREFIX}. Matching semantics are entirely the * provider's to choose (substring, prefix, exact). */ filterable?: boolean; /** * Lets a viewer hide this column from the table's column menu. Purely a * client-side display concern — a hidden column is not communicated to the * provider, which keeps returning it. The menu itself only appears when at * least one column opts in, so a table that declares none renders exactly as * it did before this flag existed. */ hideable?: boolean; } /** * Drill-down: opt a chart-shaped panel into "clicking a bar/segment/bucket filters * this dashboard". * * The UI holds the current selection and re-resolves EVERY panel on the dashboard * with `param` set to the clicked item's id (or its label when the provider gave * no id) merged onto each panel's own `DataBinding.query`. So a provider opts in * by reading that one query key; a provider that ignores it renders exactly what * it renders today. * * Omit this and the panel is inert: the UI attaches no click handler at all, which * is the difference between "clicking does nothing" and "the cursor says it should". * * @example * { kind: 'topN', title: 'Busiest workflows', data: { provider: 'durable.top' }, * drilldown: { param: 'workflow' } } * // click "checkout" → every panel re-resolves with `?workflow=checkout` */ export interface PanelDrilldown { /** Query-parameter name the selection is written to. */ param: string; } export type Panel = { kind: 'stat'; title: string; data: DataBinding; format?: 'number' | 'percent' | 'duration' | 'rate'; accent?: string; /** When true, the provider also returns `spark: number[]` and the card draws a sparkline. */ spark?: boolean; thresholds?: PanelThresholds; } | { kind: 'timeseries'; title: string; data: DataBinding; series: string[]; style?: 'area' | 'stacked'; /** Clicking a bucket filters the dashboard by its label. See {@link PanelDrilldown}. */ drilldown?: PanelDrilldown; } | { kind: 'topN'; title: string; data: DataBinding; limit?: number; /** Clicking a bar filters the dashboard by the item's `id` (or label). See {@link PanelDrilldown}. */ drilldown?: PanelDrilldown; } | { kind: 'table'; title: string; data: DataBinding; columns: Column[]; /** * Opt into paged-table mode: the UI renders prev/next controls (+ "page X * of Y") and re-resolves this panel's provider with `query.page` (1-based) * and `query.limit` merged in on top of the panel's own static `data.query`. * The provider MUST then return `{ rows, total, page, limit }` instead of * a bare `{ rows }` — see {@link DataProvider.resolve}. Omit (or `false`) * for the existing bare-rows table, unchanged. */ paged?: boolean; } | { kind: 'distribution'; title: string; data: DataBinding; markers?: Array<'p50' | 'p95' | 'p99'>; format?: 'duration' | 'number'; /** Clicking a bucket filters the dashboard by its label. See {@link PanelDrilldown}. */ drilldown?: PanelDrilldown; } | { kind: 'gauge'; title: string; data: DataBinding; min?: number; max?: number; format?: 'number' | 'percent' | 'duration' | 'rate'; thresholds?: PanelThresholds; } | { kind: 'breakdown'; title: string; data: DataBinding; style?: 'donut' | 'bar'; /** Clicking a segment filters the dashboard by its label. See {@link PanelDrilldown}. */ drilldown?: PanelDrilldown; }; /** A named server-side query a panel binds to. */ export interface DataProvider { /** Stable name referenced by a panel's `DataBinding.provider`, e.g. 'durable.timeseries'. */ name: string; /** * Resolve a panel's data. `query` is the panel's `DataBinding.query`. Return value * shape is per panel kind: * - stat → `{ value: number; delta?: number; deltaLabel?: string; spark?: number[] }` * - timeseries → `{ rows: Array<{ label: string } & Record<string, number>> }` * - topN → `{ items: Array<{ label: string; value: number; id?: string }> }` * - table → `{ rows: Array<Record<string, unknown>> }`, or — when the * panel declares `paged: true` — `{ rows, total, page, limit }` * (`page`/`limit` normally echo the requested `query.page` / * `query.limit`; `total` is the full, unpaginated row count so * the UI can compute "page X of Y") * * A `table` panel whose columns declare `sortable` / `filterable` additionally * merges `sort` + `dir` and `filter.<columnKey>` params into `query`. Read * them with {@link readTableQuery} rather than by hand — everything in `query` * arrives as a **string** off the URL, so `query.page > 1` is silently `false` * for `'2'`. A provider that ignores the new params is unaffected: the table * simply keeps returning rows in the provider's own order. * - distribution → `{ buckets: Array<{ label: string; count: number }>; p50?: number; p95?: number; p99?: number }` * - gauge → `{ value: number; min?: number; max?: number }` * - breakdown → `{ segments: Array<{ label: string; value: number; color?: string }> }` */ resolve(query: Record<string, unknown> | undefined, ctx: ExtensionContext): Promise<unknown>; } /** Identity helper for authoring extensions with full type inference. */ export declare function defineTelescopeExtension(ext: TelescopeExtension): TelescopeExtension; //# sourceMappingURL=types.d.ts.map