@adonis-agora/telescope/ui
The Telescope dashboard's read backend — a JSON API plus an SSE live-stream served from the headless TelescopeService and mounted onto your app's router behind a configurable auth guard. The dashboard page itself is the separate @adonis-agora/telescope-ui SPA, which consumes these routes.
This subpath is the dashboard's read backend. It mounts a JSON API and an SSE live-stream
directly onto your AdonisJS router — no separate service — all served from the headless
TelescopeService, behind a configurable
auth guard. The dashboard page itself is the
@adonis-agora/telescope-ui SPA, a pre-built React
bundle that consumes these routes.
Install
The dashboard ships inside the one @adonis-agora/telescope package. Enable it when configuring
telescope:
npm i @adonis-agora/telescope
node ace configure @adonis-agora/telescope # then pick "UI" at the promptSelecting UI registers @adonis-agora/telescope/ui_provider and publishes
config/telescope_ui.ts. That gives you the JSON API and the live stream — curl http://localhost:3333/telescope/api/entries works immediately.
It does not give you a page. http://localhost:3333/telescope answers 404 until you also
install @adonis-agora/telescope-ui, which owns the
mount root and serves the console that consumes these routes.
How it mounts
On boot the provider reads config/telescope_ui.ts and registers routes under the configured
prefix on your app's router: the JSON API under <path>/api/*, the SSE stream at
<path>/api/stream, and — when you configure dashboardAuth — the login and logout routes.
Everything that serves recorded data runs the authorize guard first. It reads the same live
store the watchers write to, so the API always reflects what was just captured.
It serves nothing at the prefix root itself: <path> and <path>/* belong to the
@adonis-agora/telescope-ui SPA provider, which is why
the two packages can be mounted, gated and upgraded independently.
If @adonis-agora/telescope isn't enabled/booted, the store slot is null and the provider
registers no routes (warn-logged) rather than crashing — the dashboard simply doesn't
exist until the core is running.
What it serves
| Route | Serves |
|---|---|
GET <path>/api/entries | List (filterable, capped). Compact EntrySummary rows. |
GET <path>/api/entries/:id | One entry with full content, or 404. |
GET <path>/api/trace/:traceId | Every entry on a trace. |
GET <path>/api/stats | { count, topFamilies, topTags }. |
GET <path>/api/retention | How long data survives here — see Retention. |
GET <path>/api/meta | What this install can do — see Capability discovery. |
GET <path>/api/stream (if stream enabled) | SSE live-tail of newly recorded entries. |
GET <path>/api/metrics/stats | Windowed latency/throughput/error aggregates. |
GET <path>/api/metrics/timeseries | Bucketed series for the charts. |
GET <path>/api/metrics/traces | Recent traces, newest first. |
GET <path>/api/metrics/waterfall/:traceId | One trace as a waterfall. |
GET <path>/api/metrics/n-plus-one/:traceId | Repeated-query loops detected on one trace. |
GET <path>/api/metrics/pulse (if Pulse enabled) | Pulse rollup snapshot. |
POST <path>/api/requests/:id/replay (if replay enabled) | Replay a captured request. |
POST <path>/api/exceptions/:id/diagnose (if ai configured) | Diagnose one exception — see Diagnosing an exception. |
GET <path>/api/ext/:ext/data/:provider (if any extension) | One data provider's result. |
GET/POST <path>/api/profiles* (if cpu_profiling installed) | Captured CPU profiles + arm trigger. |
GET <path>/api/schedules/live | Registered schedules + computed next-run, joined with last-run — see Live Schedules. |
GET/POST <path>/api/queues/live* (if queue-manager watcher enabled) | Live queue list/inspect/retry/enqueue — see Live Queue Manager. |
See the dashboard tour for the full API shape and query parameters.
Capability discovery
GET <path>/api/meta answers unconditionally and describes what this particular install can
do, so a front end can hide the buttons that would fail:
{
"data": {
"entryTypes": [],
"dashboards": [],
"ai": { "enabled": true },
"profiling": { "enabled": false },
"queueManager": { "enabled": false }
}
}entryTypes and dashboards list what extensions have
contributed — both empty arrays when none are installed. The three flags say whether AI
diagnosis has a model to call, whether CPU profiling is available, and whether a live queue
driver is wired. The shipped console uses them to decide whether to render the Diagnose
button, the Profiles view and the queue console at all.
Retention
GET <path>/api/retention reports the retention posture — how aggressively data is deleted and
which entry types are being sampled — so a viewer can tell "there is nothing here" apart from
"it was already pruned":
{
"data": {
"enabled": true,
"afterMs": 86400000,
"keepLast": 10000,
"intervalMs": 60000,
"sampling": [{ "type": "query", "rate": 0.1 }]
}
}enabled is whether a pruner is armed at all;
afterMs is the age cutoff, keepLast the count cap (null when unset) and intervalMs the
cycle cadence. sampling lists only the entry types recording below 100% — a type kept at rate
1 is omitted, so an empty array means nothing is being sampled away. The endpoint reports
configuration, not live pruner state: it never tells you when the last cycle ran.
The types are exported for anyone building against it:
import type {
RetentionInfo, // the `data` payload above
RetentionOptions, // { prune?, sampling? } — what the API is constructed with
RetentionPruneOptions, // { enabled, afterMs, keepLast?, intervalMs }
RetentionSamplingRate, // { type, rate }
} from '@adonis-agora/telescope/ui'Diagnosing an exception
When @adonis-agora/telescope/ai is installed and has a model to
call, POST <path>/api/exceptions/:id/diagnose runs a diagnosis over one exception entry and
returns it as rendered Markdown:
curl -X POST http://localhost:3333/telescope/api/exceptions/01JX.../diagnose
# { "data": { "markdown": "**Probable cause** …", "cached": true } }Diagnoses are cached per exception family, so asking twice about the same error costs one model
call — cached: true tells you which answer you got. Append ?force=true to spend a fresh call
anyway, which is what the console's re-run control does after you have changed the code.
The endpoint answers 404 when AI diagnosis isn't configured for this dashboard or the id isn't
an exception (server or browser-reported), and 502 when the model call failed or ran past its
timeout. This is the route behind the Diagnose button in the console's exception detail view.
Configuration
import { defineConfig } from '@adonis-agora/telescope/ui'
export default defineConfig({
enabled: true,
path: '/telescope',
// authorize: (ctx) => {
// const { auth } = ctx as unknown as { auth: { user?: { isAdmin?: boolean } } }
// return auth.user?.isAdmin === true
// },
// credentials: { token: env.get('TELESCOPE_UI_TOKEN') },
})| Key | Default | Description |
|---|---|---|
enabled | true | Master switch; false registers no routes. |
path | /telescope | URL prefix (normalized to leading slash, no trailing slash). |
authorize | default policy | Access-decision hook — Dashboard auth. |
credentials | {} | Built-in token / HTTP Basic gate for the default policy. |
dashboardAuth | unset | Built-in login page + signed session cookie — Dashboard auth. |
replay | { enabled: false } | Request-replay gate — see Request replay. |
cpuProfiling | { armEnabled: false } | Gate for POST <path>/api/profiles/arm — see CPU profiling. |
queueActions | { enabled: false } | Gate for the queue console's retry/enqueue mutations — see Live Queue Manager. |
Request replay
The dashboard can re-issue a captured request entry against your local server so you
can reproduce it with one click. This is powerful and therefore disabled by default —
replaying re-runs a real request, and a captured POST / DELETE will run again and may
mutate application state. Opt in explicitly:
import { defineConfig } from '@adonis-agora/telescope/ui'
export default defineConfig({
replay: {
enabled: true, // default false — a disabled replay endpoint answers 403
// port: 3333, // override the local target port (reverse-proxy / non-standard setups)
// timeoutMs: 30_000,
},
})With it enabled, POST <path>/api/requests/:id/replay re-issues the entry and returns the
replayed { status, durationMs, body }. Under the hood the endpoint calls the exported
replayRequest(content, options?), whose safety posture is deliberately narrow:
| Guard | Behaviour |
|---|---|
| Same-origin only | The replay always targets 127.0.0.1:<port> — never an arbitrary URL, so it can't become an SSRF primitive. Only the captured path is reused. Port resolves in order: explicit replay.port → the live dashboard request's port → PORT env → 3333 (the AdonisJS default). |
| Credential stripping | cookie, authorization, host and content-length are stripped (REPLAY_STRIPPED_HEADERS) — a replay never silently reuses the original caller's session. |
| Self-identifying | Carries x-telescope-replay: 1, and the outbound fetch is marked internal so the http-client watcher never records (or recurses on) it. |
| Bounded | A 30s timeout (REPLAY_TIMEOUT_MS) and a 4 KB response-body cap (REPLAY_BODY_CAP). Never throws — a failed call resolves to status: 0 with an error. |
import {
replayRequest,
REPLAY_BODY_CAP, // 4096
REPLAY_TIMEOUT_MS, // 30_000
REPLAY_STRIPPED_HEADERS, // Set(['cookie','authorization','host','content-length'])
type ReplayOptions, type ReplayResult, type ReplayTransport,
} from '@adonis-agora/telescope/ui'
const result: ReplayResult = await replayRequest(entry.content, { port: 3333 })
// { status: 200, durationMs: 12, body: '…up to 4 KB…' }The Adonis request watcher captures only method + path (no query string, no request
headers, no body — and even that content is redacted before storage), so a replay
reconstructs exactly that: a same-method call to the same local path. There is deliberately
nothing sensitive to forward upstream. The ReplayTransport type lets you inject a
fetch-shaped transport (tests pass a fake so they never hit the network).
Build your own UI
The data lives behind the documented JSON API, so the shipped console is optional. Disable it
and point your own front end (an admin page, a Grafana panel, a CLI) at the same <path>/api/*
endpoints, or read TelescopeService directly. The @adonis-agora/telescope/ui subpath also
exports its framework-light building blocks — TelescopeApi, the enforceGuard guard and the
toSummary / buildQuery helpers — if you want to assemble a custom mount.
Mounting on something that isn't AdonisJS
The API handlers never see an AdonisJS HttpContext. They take a UiHttpContext, a two-field
shape you can satisfy from any server:
import type { UiHttpContext, UiRequest, UiResponse } from '@adonis-agora/telescope/ui'
interface UiRequest {
method(): string
qs(): Record<string, unknown>
header(name: string): string | undefined
}
interface UiResponse {
status(code: number): UiResponse
header(name: string, value: string): UiResponse
getHeader(name: string): unknown
send(body: unknown): unknown
}getHeader is required, not optional. It is what lets the guard notice that your
authorize hook already redirected before it writes a 403 body — see Redirecting instead of
answering JSON. An adapter
that omits it will not type-check.
Everything the built-in login flow needs is exported too, if you want to mount that surface by hand rather than letting the provider register it:
| Export | What it does |
|---|---|
resolveDashboardAuth(options) | Validates and resolves a dashboardAuth block; null when unconfigured, throws when configured but unusable. |
performLogin(auth, body, basePath) | Runs the whole login decision with no HTTP types — returns ok (with the cookie value and where to go next), bad-request, or unauthorized. |
readSession(auth, cookieValue) | Verifies a session cookie, or null. |
decideDashboardAuth(auth, cookieValue, mode, requestUrl) | The guard decision itself: allow, redirect, or unauthorized. |
enforceDashboardAuth(ctx, auth, mode, basePath) | The AdonisJS-flavoured wrapper that also writes the 302 or the 401. |
sanitizeReturnTo(candidate, fallback) | The open-redirect guard on the post-login destination — only same-origin, root-relative paths survive. |
renderLoginPage(basePath) | The server-rendered login HTML. |
SESSION_COOKIE_NAME | 'telescope_dashboard_session'. |
Peer dependencies
Only @adonisjs/core (^7.3.0), which the core already requires. This subpath ships no
front-end — it is the JSON API/SSE backend. The React SPA lives in the separate
@adonis-agora/telescope-ui package, published pre-built.
Metrics API
The programmatic analytics layer of @adonis-agora/telescope — MetricsService and the pure functions behind it (percentiles, latency histograms, throughput timeseries, trace summaries, span waterfalls, N+1 detection) exported from the main package so you can compute the same numbers the dashboard shows without going through HTTP.
@adonis-agora/telescope-ui
The observability console SPA — a React single-page app with ten sections (overview, entries + live tail, traces & waterfall, Pulse, exception groups, live queues, live schedules, exports, CPU profiles, extension dashboards), a command palette and a light/dark theme, served by a thin AdonisJS provider under the same prefix and behind the same auth guard as the core JSON API it consumes.