Aviary
Concepts

Extensions

A declarative SPI for packaging watchers, a navigable entry type, dashboard pages, and server-side data providers into one installable unit — the fixed UI renders the spec, the extension ships no React.

A watcher captures one kind of activity (see Capture & correlation). An extension is the bigger unit: it bundles watchers and the dashboard around them — a navigable entry type, declarative dashboard pages, and the server-side queries those pages read — into one object you register with forRoot. It's how a package like @dudousxd/nestjs-durable-telescope adds a whole "Workflows" surface to your Telescope without shipping a line of UI code.

import { TelescopeModule } from '@dudousxd/nestjs-telescope';
import { durableTelescopeExtension } from '@dudousxd/nestjs-durable-telescope';

TelescopeModule.forRoot({
  extensions: [durableTelescopeExtension()],
});

extensions is additive and backward compatible: a watcher contributed by an extension is merged into the same watchers list the built-ins use, so the two options coexist.

The contract

An extension is a plain object — author it with defineTelescopeExtension for inference. It has a name (used in collision errors) and four optional hooks, each handed an ExtensionContext:

interface TelescopeExtension {
  name: string;
  watchers?(ctx: ExtensionContext): Watcher[];
  entryTypes?(ctx: ExtensionContext): { id: string; label: string; dot: string }[];
  dashboards?(ctx: ExtensionContext): DashboardSpec[];
  dataProviders?(ctx: ExtensionContext): DataProvider[];
}

interface ExtensionContext {
  readonly moduleRef: ModuleRef;       // resolve host services
  readonly config: ResolvedCoreConfig;
}

The hooks are multi-hooks: every registered extension runs, and the results accumulate. They run once, eagerly, at module init — so a misconfiguration fails at boot, not on first request.

  • watchers — contribute watchers. Same Watcher SPI as everything else; they land in the merged watchers list.
  • entryTypes — contribute navigable entry types. Each is { id, label, dot }, where id is the backend type filter (e.g. 'durable'), label is the nav label, and dot is a Tailwind bg-* class for the nav dot. This is what makes the dashboard nav dynamic instead of hard-coded.
  • dashboards — contribute declarative dashboard pages (the panel IR below).
  • dataProviders — named server-side queries that panels bind to.

Resolve host services through ctx.moduleRef — e.g. a durable engine, a store token, or TELESCOPE_STORAGE. The extension never imports the host app; it asks the Nest container for what it needs (use { strict: false } so it finds providers in any module).

The panel IR

A dashboard page is a DashboardSpec, and its panels are a small, closed set of declarative shapes. The extension emits this spec; the fixed UI renders it — there is no React in an extension. That's the whole trick: the surface is data, not code.

interface DashboardSpec {
  id: string;         // globally unique, "<extName>.<page>"
  label: string;
  navGroup?: string;  // optional nav grouping header
  panels: Panel[];
}

A Panel is one of four kinds. Each binds its data to a provider via a DataBinding = { provider, query? }:

KindShapeProvider returns
stat{ kind:'stat', title, data, format?: 'number'|'percent'|'duration', accent? }{ value: number }
timeseries{ kind:'timeseries', title, data, series: string[], style?: 'area'|'stacked' }{ rows: ({ label } & Record<string, number>)[] }
topN{ kind:'topN', title, data, limit? }{ items: { label, value, id? }[] }
table{ kind:'table', title, data, columns: { key, label, link?, sortable?, filterable?, hideable? }[], paged? }{ rows: Record<string, unknown>[] } — or paged shape below

A table column can deep-link out with link.href — a URL template with {key} placeholders filled from the row:

{ key: 'runId', label: 'Run', link: { href: '/durable/runs/{runId}' } }

LinkSpec — two href conventions

link.href is one of two shapes, and the UI tells them apart by whether the template starts with #/:

  • In-app hash routehref starting with #/ (e.g. '#/traces/{traceId}') is a route inside the Telescope SPA itself. It renders as a plain anchor; a same-document #-only href is a same-document navigation (URL hash update, 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; it already navigates in-app.
  • 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 to open it in a new tab.

The one confirmed in-app hash route today is the trace waterfall: #/traces/{traceId} (traceId is the row key substituted in), which renders the single-trace waterfall page. Point a table column there to deep-link "show me this trace" straight out of an extension's own dashboard:

{ key: 'traceId', label: 'Trace', link: { href: '#/traces/{traceId}' } } // in-app, no `external`
{ key: 'runId', label: 'Run', link: { href: '/durable/runs/{runId}' }, external: true } // host console, new tab

Paged tables

A table panel can opt into pagination with paged: true:

{
  kind: 'table',
  title: 'Recent failures',
  data: { provider: 'durable.recentFailures' },
  columns: [/* ... */],
  paged: true,
}

This changes both sides of the contract:

  • The UI renders prev/next controls plus a "Page X of Y" indicator, and re-resolves the panel's provider on every page change with query.page (1-based) and query.limit merged in on top of the panel's own static data.query. The first request (before the user has paged) asks for page 1 at the UI's default page size.
  • The provider must then return { rows, total, page, limit } instead of the bare { rows } a non-paged table returns — total is the full, unpaginated row count so the UI can compute how many pages exist; page/limit normally just echo back what was requested.

Omit paged (or set it false) for the original bare-{ rows } table — that variant is byte-identical to before pagination existed, so existing extensions don't need to change anything.

Sortable and filterable columns

Sorting and filtering a table panel are server-side, and that is a deliberate choice rather than an unfinished one. A panel renders the one page the provider returned; sorting that in the browser would order the 50 rows in hand and present the result as "the top of the list", which is worse than not offering to sort at all. So a column opts in, and the choice travels back to the provider:

columns: [
  { key: 'runId', label: 'Run', filterable: true },
  { key: 'duration', label: 'Duration', sortable: true },
  { key: 'worker', label: 'Worker', hideable: true },
]
FlagUIReaches the provider as
sortableThe header becomes a button that cycles ascending → descending → unsortedsort=<column key> and dir=asc|desc (both absent when unsorted)
filterableA filter box appears under the header, committed on Enter or blurfilter.<column key>=<text> (absent when the box is empty)
hideableThe column appears in the panel's Columns menunothing — hiding a column is display-only, and the provider keeps returning it

Filters are namespaced with filter. so a filterable column can never collide with a key the panel already declared in data.query: a panel scoped to { status: 'running' } and carrying a filterable status column is an ordinary combination, and unprefixed one would silently overwrite the other.

On the provider side, read the state with readTableQuery rather than by hand:

import { readTableQuery } from '@dudousxd/nestjs-telescope';

{
  name: 'durable.runs',
  async resolve(query) {
    const { page = 1, limit = 50, sort, filters } = readTableQuery(query);
    const result = await store.findRuns({
      offset: (page - 1) * limit,
      limit,
      orderBy: sort ? { [sort.key]: sort.dir } : { startedAt: 'desc' },
      where: filters,
    });
    return { rows: result.items, total: result.total, page, limit };
  },
}

The helper exists because everything in query arrives as a string off the URL — query.page > 1 is silently false for '2', with no type error to catch it — and because a hand-typed ?page=banana should degrade to "first page" rather than reach a LIMIT clause as NaN. It returns { page?, limit?, sort?: { key, dir }, filters }, normalizing numbers, dropping an emptied filter (filter.status= means no filter, not "match the empty string"), and reading an unrecognized dir as ascending. Matching semantics for a filter — substring, prefix, exact — are entirely the provider's to choose.

All of this is additive. A provider that never reads the new params keeps working exactly as before, and a panel whose columns declare none of the three flags renders exactly the table it rendered before they existed: no header buttons, no filter row, no column menu, and the identical query on the wire.

Only mark a column sortable if the provider honours it

Nothing validates the flag against the provider. A sortable column whose provider ignores sort gives the viewer a header that visibly changes state and returns the same rows — a control that lies. Same for filterable.

DataProvider output bypasses entry-level redact

A DataProvider's return value is sent to the browser as-isGET .../api/ext/:ext/data/:provider returns whatever resolve() returns with no redaction pass in between (unlike Entry.content, which always goes through the Recorder's redact() step before it's ever stored). If a provider reads live data from a host service — a durable run's payload, a job's arguments, anything that might carry a secret — the provider itself is responsible for masking it before returning. Don't assume "it's rendered through Telescope's dashboard" implies "it's redacted"; that guarantee only applies to captured Entry content, not to extension-provider responses.

The dashboard-id convention

A DashboardSpec.id must be globally unique and follows "<extName>.<page>" — e.g. durable.workflows. This isn't cosmetic: the UI derives the owning extension from the id prefix to know which extension's providers to resolve a page's panels against. Name your providers the same way (durable.timeseries, durable.recentFailures) so the mapping is obvious.

Data providers and the request flow

A DataProvider is a named, server-side query a panel reads:

interface DataProvider {
  name: string;
  resolve(query: Record<string, unknown> | undefined, ctx: ExtensionContext): Promise<unknown>;
}

When the UI renders a panel, it fetches that panel's binding from a single endpoint:

GET <path>/api/ext/:ext/data/:provider?<query>

The server looks up the provider by name, builds an ExtensionContext, and calls resolve(query, ctx). Query params arrive as strings and are passed through verbatim — including the paging, sort and filter params a table panel adds (see Sortable and filterable columns, and use readTableQuery to read them back as numbers). An unknown provider is a 404; a provider that throws surfaces a 502 with its message (so a panel author can see why a panel is empty).

The read gate applies

The data endpoint sits behind the same read authorizer as the rest of the dashboard API — which denies in production by default until you configure one. Extension data is never a side door: if the dashboard is gated, the panels are gated too. See The gate.

Collisions

Because the hooks accumulate across every extension, the registry guards the shared namespaces. A duplicate entry-type id, dashboard id, or provider name across two extensions throws at boot, naming both extensions:

Telescope data provider "durable.timeseries" is contributed by both
"durable" and "other". Provider names must be unique.

This is why the <extName>. prefix matters — it keeps your ids from colliding with another installed extension's.

Single-slot hooks are reserved. Today every hook is multi (all extensions contribute, results merge). A hook that only one extension may own — overriding a piece of the host UI — is intentionally not part of the 0.x contract. The registry is shaped to add one when a real consumer needs it.

A minimal worked example

A complete extension: one entry type, a one-panel dashboard, and the provider that feeds it.

import { defineTelescopeExtension } from '@dudousxd/nestjs-telescope';

export function jobsTelescopeExtension() {
  return defineTelescopeExtension({
    name: 'jobs',

    entryTypes: () => [
      { id: 'job', label: 'Jobs', dot: 'bg-sky-400' },
    ],

    dataProviders: () => [
      {
        name: 'jobs.pending',
        async resolve(_query, ctx) {
          const store = ctx.moduleRef.get('TELESCOPE_STORAGE', { strict: false });
          const pending = await store.countByTag('status:pending');
          return { value: pending }; // stat → { value: number }
        },
      },
    ],

    dashboards: () => [
      {
        id: 'jobs.overview', // "<extName>.<page>"
        label: 'Jobs',
        panels: [
          {
            kind: 'stat',
            title: 'Pending jobs',
            format: 'number',
            data: { provider: 'jobs.pending' },
          },
        ],
      },
    ],
  });
}
TelescopeModule.forRoot({
  extensions: [jobsTelescopeExtension()],
});

That's it: registering the extension adds a Jobs nav entry, a Jobs dashboard page, and a stat panel that fetches GET <path>/api/ext/jobs/data/jobs.pending and renders the number — no UI code shipped.

The canonical real-world extension is @dudousxd/nestjs-durable-telescope's durableTelescopeExtension(): it registers a durable entry type plus a durable.workflows dashboard (success-rate, failed, and dead-now panels) backed by durable.state / durable.timeseries / durable.recentFailures providers.

For a step-by-step build, see Building an extension.

On this page