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.
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 kind | Renders | Provider returns |
|---|---|---|
stat | A single number (+ optional spark, thresholds) | { value, delta?, deltaLabel?, spark? } |
timeseries | A line/area chart | { rows: Array<{ label } & Record<string, number>> } |
topN | A ranked list | { items: Array<{ label, value, id? }> } |
table | A table (cells can deep-link) | { rows: Array<Record<string, unknown>> }, or { rows, total, page, size } when paged |
distribution | A histogram with p50/p95/p99 | { buckets: Array<{ label, count }>, p50?, p95?, p99? } |
gauge | A gauge with thresholds | { value, min?, max? } |
breakdown | A 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:
| Key | On | Effect |
|---|---|---|
format | stat, gauge | 'number', 'percent', 'duration' or 'rate' — how the value is written out. |
thresholds | stat, gauge | { warn, bad, direction }, where direction is 'up-bad' (latency, errors) or 'down-bad' (hit rate, success rate). The panel colours itself accordingly. |
accent | stat | A CSS colour for the card's accent, when the default doesn't fit the metric. |
spark | stat | Draws a sparkline under the number. Turning it on means your provider must also return spark: number[]. |
style | timeseries | 'area' or 'stacked'. |
series | timeseries | Required — the row keys to plot, in order. |
limit | topN | How many rows to show. |
markers | distribution | Which percentile markers to draw: any of 'p50', 'p95', 'p99'. |
format | distribution | 'duration' or 'number' for the bucket labels. |
min / max | gauge | The gauge's ends. |
style | breakdown | 'donut' or 'bar'. |
paged | table | Adds pagination — and changes the provider contract, see below. |
cols | DashboardSection | 2, 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
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.
Storage
The TelescopeStore contract every watcher records through and the query API reads from — the config-driven driver model (the in-memory ring buffer and the SQL-backed Lucid store), the EntryQuery filter model, retention and pruning.
Performance
Why capture doesn't slow the app it observes — request recording sits in a finally block off the response path, watchers are fire-and-forget, the in-memory store is bounded, and every recording is guarded so a failing store can never break or block a hot path.