Aviary
Recipes

Building an extension

Package a watcher, a navigable entry type, a server-side data provider, and a declarative dashboard page into one installable Telescope extension — step by step, using the durable workflows surface as the worked example.

An extension bundles everything a feature needs to show up in Telescope — a navigable entry type, dashboard pages, and the server-side queries those pages read — into one object you register with forRoot. The extension ships no UI: it emits a declarative spec, and the fixed dashboard renders it. This recipe builds one end to end, using the shape of @dudousxd/nestjs-durable-telescope (its durableTelescopeExtension()) as the worked example.

See Extensions for the full contract and the panel IR.


1. Define the extension

Author the object with defineTelescopeExtension for inference. Wrap it in a factory so consumers can pass options:

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

export function durableTelescopeExtension() {
  return defineTelescopeExtension({
    name: 'durable', // used in collision errors + the "<name>." id prefix
  });
}

The name is the extension's identity. It shows up in collision errors and, by convention, prefixes every dashboard id and provider name you contribute.


2. Add an entry type

entryTypes makes the dashboard nav dynamic. Each entry is { id, label, dot }: id is the backend type filter, label is the nav label, and dot is a Tailwind bg-* class for the nav dot.

entryTypes: () => [
  { id: 'durable', label: 'Workflows', dot: 'bg-amber-400' },
],

Now durable entries (recorded by your watcher) are browsable from the nav like any built-in type.


3. Add a data provider

A DataProvider is a named server-side query. It resolves a host service through ctx.moduleRef — never import the host app, ask the Nest container for what you need. Use { strict: false } so it finds the provider in any module:

import type { DurableEngine } from 'your-durable-package';

dataProviders: () => [
  {
    name: 'durable.state', // "<name>.<provider>"
    async resolve(_query, ctx) {
      const engine = ctx.moduleRef.get<DurableEngine>('DURABLE_ENGINE', {
        strict: false,
      });
      const dead = await engine.countDead();
      return { value: dead }; // stat → { value: number }
    },
  },
],

The return shape depends on the panel kind that binds to it:

Panel kindProvider returns
stat{ value: number }
timeseries{ rows: ({ label } & Record<string, number>)[] }
topN{ items: { label, value, id? }[] }
table{ rows: Record<string, unknown>[] } — or, for a paged: true table, { rows, total, page, limit } (see step 4)

The provider runs behind the dashboard's read authorizer, fetched at GET <path>/api/ext/:ext/data/:provider. Query params arrive as strings — coerce them inside resolve if you need numbers or dates.

Redact before you return

The response goes to the browser exactly as resolve() returns it — there is no entry-level redaction pass on provider output the way there is for captured Entry.content. If your provider surfaces anything that could carry a secret (a run's stored payload, a job's raw args), mask it inside resolve yourself before returning.


4. Add a dashboard spec

A DashboardSpec is a page of declarative panels. Its id must be globally unique and follows "<extName>.<page>" — the UI derives the owning extension from the id prefix to resolve the page's providers, so the prefix is load-bearing, not cosmetic.

Each panel binds its data to a provider via { provider, query? }:

dashboards: () => [
  {
    id: 'durable.workflows', // "<name>.<page>" — globally unique
    label: 'Workflows',
    panels: [
      {
        kind: 'stat',
        title: 'Dead now',
        format: 'number',
        data: { provider: 'durable.state' },
      },
      {
        kind: 'timeseries',
        title: 'Throughput',
        series: ['completed', 'failed'],
        style: 'stacked',
        data: { provider: 'durable.timeseries', query: { window: '24h' } },
      },
      {
        kind: 'table',
        title: 'Recent failures',
        data: { provider: 'durable.recentFailures' },
        paged: true, // adds prev/next + "Page X of Y"; see below
        columns: [
          { key: 'workflow', label: 'Workflow', filterable: true }, // adds a filter box
          {
            key: 'traceId',
            label: 'Trace',
            link: { href: '#/traces/{traceId}' }, // in-app hash route — the trace waterfall
          },
          {
            key: 'runId',
            label: 'Run',
            link: { href: '/durable/runs/{runId}', external: true }, // host-console page, new tab
          },
        ],
      },
    ],
  },
],

A table column's link.href is a URL template — {runId} / {traceId} are replaced with that row's value. Two conventions, told apart by a leading #/:

  • '#/traces/{traceId}' — an in-app hash route inside the Telescope SPA (today, only the trace waterfall). It already navigates in-app; don't set external.
  • '/durable/runs/{runId}' — a host-console page (your app, not Telescope). Set external: true to open it in a new tab.

Because this table declares paged: true, durable.recentFailures's resolve() must return { rows, total, page, limit } (not a bare { rows }) — total is the full, unpaginated count so the UI can render "Page X of Y". A non-paged table is unaffected; add paged: true only when a table's row count can outgrow one screen. See Extensions → Paged tables for the full contract.

The filterable: true on the workflow column works the same way: it adds a filter box whose committed text reaches resolve() as filter.workflow=<text>, and sortable: true on a column sends sort + dir. Both are server-side — the panel only ever holds one page, so a browser-side sort would order those rows and pass the result off as the top of the list. Read them with readTableQuery(query), which also parses page/limit out of the strings the URL delivers:

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

const { page = 1, limit = 50, sort, filters } = readTableQuery(query);

Only flag a column once the provider actually honours it — nothing checks, and a sort control that returns the same rows is worse than no sort control. See Extensions → Sortable and filterable columns.


5. Register it

Pass the extension to forRoot. It's additive — extensions coexists with watchers (an extension's own watchers are merged into the same list):

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

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

That single registration adds the Workflows nav entry, the Workflows dashboard page, and wires every panel to its provider.

Keep ids unique

Entry-type ids, dashboard ids, and provider names are global namespaces shared across every installed extension. A duplicate throws at boot, naming both owners. Prefix everything with your extension's name (durable.workflows, durable.timeseries) so two installed extensions never collide.


What you didn't write

No React, no routes, no controller. The extension is a spec plus a few async queries; the fixed UI fetches the data and renders the panels. To go deeper on the panel IR, the data request flow, and the read gate, see Extensions.

On this page