Agora
Concepts

Extensions

The declarative extension SPI — how a sibling library contributes navigable entry types, declarative dashboard pages (the panel IR), and server-side data providers to Telescope without forking it or shipping any React.

An extension lets a sibling library — say @adonis-agora/durable — add a first-class Workflows page to the Telescope dashboard, complete with stat cards, time series and tables backed by its own data, without @adonis-agora/telescope knowing anything about it and without the extension author writing a single line of React. It is the Adonis port of aviary's NestJS extension SPI.

The contract

An extension is a plain object — usually returned by a factory so it can take options — with up to three hooks. Author it with defineTelescopeExtension for full inference:

import { defineTelescopeExtension } from '@adonis-agora/telescope'

interface TelescopeExtension {
  name: string                                              // unique id
  entryTypes?(ctx: ExtensionContext): ExtensionEntryType[]  // navigable nav entries
  dashboards?(ctx: ExtensionContext): DashboardSpec[]       // declarative pages
  dataProviders?(ctx: ExtensionContext): DataProvider[]     // server-side queries
}

You register extensions in config/telescope.ts; the provider runs every hook once at boot. Hooks are multi — every extension runs and results accumulate.

config/telescope.ts
import { defineConfig } from '@adonis-agora/telescope'
import { durableTelescopeExtension } from '@adonis-agora/durable-telescope'

export default defineConfig({
  extensions: [durableTelescopeExtension()],
})

The ExtensionContext

Every hook receives a read-only context — the Adonis replacement for NestJS's ModuleRef:

interface ExtensionContext {
  readonly store: TelescopeStore   // read recorded entries for aggregation
  readonly container: ContainerLike // resolve host services: await ctx.container.make(WorkflowEngine)
  readonly config: ResolvedTelescopeConfig
}

So a data provider can read Telescope's own entries and reach into the host app's services to compute whatever a panel needs.

Entry types

An entryTypes hook makes the dashboard's entry-type navigation dynamic — each entry maps a backend type/tag filter to a nav label and a colored dot:

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

The panel IR

A dashboard page is a DashboardSpec — a declarative description the UI renders. You never ship components; you describe panels and the UI draws them.

interface DashboardSpec {
  id: string             // stable, globally-unique route id, e.g. 'durable.workflows'
  label: string          // nav label
  navGroup?: string      // optional nav grouping header
  panels: Panel[]        // flat layout
  sections?: DashboardSection[]  // sectioned layout (preferred for hierarchy)
}

Each Panel binds to a named data provider and declares how to draw the result:

Panel kindRendersProvider returns
statA single number (+ optional spark, thresholds){ value, delta?, deltaLabel?, spark? }
timeseriesA line/area chart{ rows: Array<{ label } & Record<string, number>> }
topNA ranked list{ items: Array<{ label, value, id? }> }
tableA table (cells can deep-link){ rows: Array<Record<string, unknown>> }, or { rows, total, page, size } when paged
distributionA histogram with p50/p95/p99{ buckets: Array<{ label, count }>, p50?, p95?, p99? }
gaugeA gauge with thresholds{ value, min?, max? }
breakdownA donut/bar breakdown{ segments: Array<{ label, value, color? }> }

A panel references its data through a DataBinding{ provider, query? } — where provider names a DataProvider and query is an opaque object passed through to it.

Tuning how a panel is drawn

Every kind takes a few optional keys beyond title and data:

KeyOnEffect
formatstat, gauge'number', 'percent', 'duration' or 'rate' — how the value is written out.
thresholdsstat, gauge{ warn, bad, direction }, where direction is 'up-bad' (latency, errors) or 'down-bad' (hit rate, success rate). The panel colours itself accordingly.
accentstatA CSS colour for the card's accent, when the default doesn't fit the metric.
sparkstatDraws a sparkline under the number. Turning it on means your provider must also return spark: number[].
styletimeseries'area' or 'stacked'.
seriestimeseriesRequired — the row keys to plot, in order.
limittopNHow many rows to show.
markersdistributionWhich percentile markers to draw: any of 'p50', 'p95', 'p99'.
formatdistribution'duration' or 'number' for the bucket labels.
min / maxgaugeThe gauge's ends.
stylebreakdown'donut' or 'bar'.
pagedtableAdds pagination — and changes the provider contract, see below.
colsDashboardSection2, 3 or 4 — the column count for that section's panels.

Deep-linking out of a table

Table columns can carry a LinkSpec{ href, external? } — whose href is a template with {key} placeholders filled from the row (each value URL-encoded; a null becomes empty):

columns: [
  { key: 'runId', label: 'Run', link: { href: '/durable/runs/{runId}' } },
  { key: 'traceId', label: 'Trace', link: { href: '#/traces/{traceId}' } },
]

The leading character decides where it goes. An href starting with #/ is a route inside the Telescope console — today the one such route is #/traces/{traceId}, the trace waterfall, so that is the shape to use when a row should link to "show me this trace". Anything else is a real navigation into your application's own pages; set external: true when it should open in a new tab.

Paged tables

A table panel with paged: true renders prev/next controls and a "Page X of Y" label. That changes what the bound provider must return: instead of a bare { rows }, it must answer

{ rows, total, page, size }

where total is the full, unpaginated row count (that is what makes "of Y" computable) and page / size echo what was asked for. { page, size } is the pagination pair every @adonis-agora/* library uses (the same shape as @adonis-agora/filter's FilterInput). The console re-resolves the provider on every page turn, merging page (1-based) and size on top of the panel's own static query. Both arrive as strings, like every other query value, so coerce them:

{
  name: 'durable.runs',
  async resolve(query, ctx) {
    const page = Math.max(1, Number(query?.page ?? 1))
    const size = Math.max(1, Number(query?.size ?? 25))
    const { rows, total } = await fetchRuns({ offset: (page - 1) * size, limit: size })
    return { rows, total, page, size }
  },
}

A provider that returns a bare { rows } to a paged: true panel still renders its rows, but the pager has nothing to count with — it reports a single page and the next button never enables. Set paged and the four-field return shape together, or neither.

The dashboard-id convention

Prefix ids with your extension name (durable.workflows, durable.timeseries). Ids and provider names are global across all extensions; the prefix keeps them collision-free and makes ownership obvious.

Data providers

A DataProvider is a named server-side query a panel binds to:

interface DataProvider {
  name: string   // referenced by a panel's DataBinding.provider
  resolve(query: Record<string, unknown> | undefined, ctx: ExtensionContext): Promise<unknown>
}

resolve receives the panel's DataBinding.query merged with the request query string, plus the same ExtensionContext (store + container). Its return shape must match the binding panel's kind (the table above). The UI's JSON API exposes providers at <path>/api/ext/:ext/data/:provider, namespaced by the owning extension — so one extension can never be addressed under another's name.

Collisions

The registry validates everything at boot and fails closed:

Two extensions cannot contribute the same entry-type id, dashboard id, or provider name. A collision throws at boot, naming both owners — so drift is a loud startup error, not a confusing runtime mystery. Each accessor returns copies, and the registry tracks the owning extension so the HTTP layer can enforce the namespace.

A minimal worked example

packages/durable-telescope/src/extension.ts
import { defineTelescopeExtension } from '@adonis-agora/telescope'

export function durableTelescopeExtension() {
  return defineTelescopeExtension({
    name: 'durable',

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

    dataProviders: () => [
      {
        name: 'durable.activeRuns',
        async resolve(_query, ctx) {
          const engine = await ctx.container.make('durable.engine')
          return { value: await engine.countActive() }
        },
      },
    ],

    dashboards: () => [
      {
        id: 'durable.workflows',
        label: 'Workflows',
        panels: [
          {
            kind: 'stat',
            title: 'Active runs',
            data: { provider: 'durable.activeRuns' },
            format: 'number',
          },
        ],
      },
    ],
  })
}

The host adds durableTelescopeExtension() to config/telescope.ts. The UI fetches the contributed metadata from <path>/api/meta, renders the Workflows page from the spec, and resolves each panel through <path>/api/ext/durable/data/durable.activeRuns.

What you did not write: a React component, a route, an auth check, a fetch hook, or anything Telescope-internal. The spec is data; the UI is generic.

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

On this page