@dudousxd/adonis-telescope
v0.6.0
Published
An elegant debug assistant for AdonisJS — requests, queries, exceptions, jobs, mail, events, logs and more. Zero config.
Maintainers
Readme
@dudousxd/adonis-telescope
An elegant debug assistant for AdonisJS — inspired by Laravel Telescope.
Records requests, database queries, exceptions, validation failures,
logs, events, mail, queue jobs, model writes, authorization checks,
Transmit broadcasts, outgoing HTTP calls, rate-limit hits, cache, Redis
and browser errors — and presents everything in a beautiful live
dashboard at /telescope, with batch correlation (everything that
happened during one request, grouped with a waterfall view).
Zero configuration. Install it, register the provider, done. Every official AdonisJS package is detected and instrumented automatically.
Installation
node ace add @dudousxd/adonis-telescopeOr manually:
pnpm add @dudousxd/adonis-telescope// adonisrc.ts
providers: [
// ...
() => import('@dudousxd/adonis-telescope/telescope_provider'),
]Start your server and open /telescope. That's it.
What gets captured (zero config)
| Watcher | Source | Mechanism |
|---|---|---|
| Requests | @adonisjs/http-server | adonisjs.http.request tracing channel + http:request_completed event |
| Queries | @adonisjs/lucid | db:query event (debug force-enabled per connection) |
| Exceptions | exception handler | patched report() on your handler class |
| Validation | @vinejs/vine | E_VALIDATION_ERROR reports, with the failed fields |
| Throttle | @adonisjs/limiter | E_TOO_MANY_REQUESTS reports |
| Logs | @adonisjs/logger | pino write hook (covers ctx.logger children) |
| Events | @adonisjs/events | emitter.onAny() (framework noise filtered) |
| Mail | @adonisjs/mail | mail:sent / mail:queued / queued:mail:error |
| Jobs | @adonisjs/queue | boringqueue.job.* tracing channels |
| Models | @adonisjs/lucid | model writes synthesized from db:query, with changed columns |
| Authorization | @adonisjs/bouncer | authorization:finished event (ability, result, user) |
| Broadcasts | @adonisjs/transmit | Transmit lifecycle hooks (broadcast/subscribe/unsubscribe) |
| HTTP client | global fetch / undici | undici:request:* diagnostics channels |
| Commands | ace | console batch per command execution |
| Cache | @adonisjs/cache | cache:* events (no-op when not installed) |
| Redis | @adonisjs/redis | instrumented sendCommand (no-op when not installed) |
| Client errors | your frontend | public rate-limited ingestion endpoint |
| Translations | @adonisjs/i18n | i18n:missing:translation event |
| Locks | @adonisjs/lock | instrumented acquire/run/release with wait time (contention) |
| Drive | @adonisjs/drive | instrumented disk operations with timing |
| Renders | Edge / Inertia | template/component, duration, Inertia props size |
Every entry recorded during a request/job/command is correlated into a
batch via AsyncLocalStorage — open any entry and see the full
timeline of what happened around it.
Dashboard
- Overview — throughput chart, p50/p95/p99, slowest requests/queries, N+1 suspects (same query shape repeated ≥5× inside one request), failing validations, exception families, cache hit rate, telescope health (recorded/dropped/storage/live viewers).
- Live tail — new entries are pushed over SSE and flash in at the top of the list (polling fallback included).
- Per-type lists — full-text search, cursor pagination.
- Entry drawer — type-specific details (SQL highlighting, stack traces with app-frame emphasis, mail HTML preview, validation field errors, model change diffs), raw JSON, batch waterfall.
- Routes page — Pulse-style per-route table (requests, errors, avg, p95) with drill-down into the matching requests.
- Query EXPLAIN — one click runs
EXPLAIN(read-only, never executes) on the captured SQL and shows the plan. - Copy as cURL / Replay — reproduce any captured request instantly,
or replay it server-side with one click (tagged
replay). - HAR export — download a batch as a
.harfile and open it in the browser DevTools network panel. - Filter chips, time range (1h/6h/24h), light theme, j/k keyboard navigation.
- Queues — live queue depth per status from the
@adonisjs/queuedatabase driver, with retry/remove for failed jobs. - AI diagnosis — see below.
MCP server — let your coding agent debug from real data
Telescope exposes an MCP endpoint at /telescope/mcp (streamable
HTTP, stateless) so Claude Code, Cursor and friends can query what
actually happened: "why is POST /checkout slow?" → the agent pulls the
batch waterfall with every query.
claude mcp add --transport http telescope http://localhost:3333/telescope/mcpTools: list_entries, get_entry (with the full batch), get_batch,
get_stats, diagnose_exception.
Auth: mcp: { token } (falls back to dashboard.token) as a Bearer
token; outside production it works without one. Disable with
mcp: false.
Production-grade capture
Sampling — keep a fraction per type, but never lose the interesting ones:
sampling: { cache: 0.1, query: { rate: 0.5, keepSlowMs: 1000, keepErrors: true }, }Overload guard — recording auto-pauses when the event loop p99 lag crosses 200ms (configurable via
overloadProtection) and resumes when the process recovers.Server vitals — CPU, heap, RSS and event loop lag sampled every 10s, charted on the overview.
Per-route aggregates — Pulse-style table (requests, errors, avg, p95 per route) with drill-down into the matching requests.
Distributed tracing
Every request, job and command is a trace. Batches always carry a
W3C trace id — inherited from an incoming traceparent, adopted from
the active OpenTelemetry span, or derived from the batch id. The
Traces page lists every flow; the detail view is a Jaeger-style
waterfall: a hierarchical span tree on a shared time axis with
collapsible nodes, per-type colors and error highlighting.
With tracePropagation (on by default) outgoing fetch calls carry a
traceparent header — two services running telescope (or anything
OTel-compatible) join up into one trace, and downstream requests nest
under the http_client call that triggered them in the waterfall.
OpenTelemetry (@adonisjs/otel)
Running the first-party OTel package? Telescope batches automatically
adopt the active span's trace id (zero config). And one line puts your
custom spans (record(), @span) into the waterfall with their real
otel parent/child ids:
// config/otel.ts
import { defineConfig } from '@adonisjs/otel'
import { telescopeSpanProcessor } from '@dudousxd/adonis-telescope/otel'
export default defineConfig({
spanProcessors: [telescopeSpanProcessor()],
})SDK instrumentation spans (http/lucid/redis) are skipped by default —
telescope's own watchers already cover those. Opt in with
telescopeSpanProcessor({ includeInstrumentationSpans: true }).
AI exception diagnosis (Vercel AI SDK — provider agnostic)
One click on an exception sends the error + stack + the request and queries from the same batch to an LLM and renders probable cause / where to look / suggested fix.
Powered by the Vercel AI SDK (optional peer).
Telescope itself knows nothing about providers — pass any AI SDK
LanguageModel:
pnpm add ai @ai-sdk/anthropic # or @ai-sdk/openai, @ai-sdk/google, ollama-ai-provider, …import { anthropic } from '@ai-sdk/anthropic'
// import { openai } from '@ai-sdk/openai'
// import { google } from '@ai-sdk/google'
export default defineConfig({
ai: { model: anthropic('claude-sonnet-4-6') },
// ai: { model: openai('gpt-5') },
// ai: { model: google('gemini-2.5-flash') },
// ai: { model: 'anthropic/claude-sonnet-4-6' }, // AI Gateway model-id string
})Any gateway / local model: a string
model+baseURLhits any OpenAI-compatible endpoint — OpenRouter, LiteLLM, Groq, LM Studio, vLLM, a corporate proxy:ai: { model: 'anthropic/claude-sonnet-4.6', baseURL: 'https://openrouter.ai/api/v1', apiKey: env.get('OPENROUTER_API_KEY'), }Zero config: with no
modelset, telescope auto-detects whichever credential the environment already carries —ANTHROPIC_API_KEY,OPENAI_API_KEY,GOOGLE_GENERATIVE_AI_API_KEY,OPENROUTER_API_KEYorAI_GATEWAY_API_KEY— and uses the matching installed@ai-sdk/*package.No AI SDK at all: bring your own function:
ai: { diagnose: async ({ exception, batch }) => '…markdown…', }
Dashboard auth
By default the dashboard is enabled outside production and denied in production. Three ways to open it up safely:
1. Custom authorizer (recommended — integrates with anything):
export default defineConfig({
enabled: true,
authorize: async (ctx) => {
const user = ctx.auth?.user
return user?.isAdmin === true
},
})2. Redirect-based auth — e.g. @dudousxd/adonis-authkit:
Return { redirectTo } and browsers are sent to your login page while
API calls get a 401:
import { hasAccountSession, consoleLoginUrl } from '@dudousxd/adonis-authkit-server'
export default defineConfig({
enabled: true,
authorize: async (ctx) =>
hasAccountSession(ctx) ? true : { redirectTo: consoleLoginUrl('/telescope') },
/**
* Agents have no browser session — give the MCP endpoint its own
* Bearer token while humans go through the authkit login:
*/
mcp: { token: env.get('TELESCOPE_TOKEN') },
})3. Built-in token login (zero wiring — great for staging):
export default defineConfig({
enabled: true,
dashboard: { token: env.get('TELESCOPE_TOKEN') },
})Visitors get a login screen; the token is stored in an encrypted cookie
(signed with your appKey, 8h TTL, /telescope/logout to clear).
Persistent storage (multi-process)
The default storage is an in-memory ring buffer. Switch to the database storage to survive restarts and share entries between the web server and queue workers:
export default defineConfig({
storage: 'database', // creates the telescope_entries table automatically
database: { connection: 'postgres', table: 'telescope_entries' },
prune: { olderThanHours: 24 }, // retention, checked hourly
})Or Redis — uses your app's own @adonisjs/redis connection by
default, or any ioredis-compatible client you pass:
export default defineConfig({
storage: 'redis',
redis: { connection: 'main' }, // app's @adonisjs/redis
// redis: { client: myIoredis }, // …or bring your own client
})Or implement the TelescopeStorage interface for anything else.
Alerts
Fire a webhook the first time a new exception family shows up:
export default defineConfig({
alerts: {
slack: env.get('SLACK_WEBHOOK_URL'), // formatted Slack message
discord: env.get('DISCORD_WEBHOOK_URL'), // formatted Discord message
email: '[email protected]', // via the app's own @adonisjs/mail
// or: webhook: 'https://…' // raw JSON POST
/** Threshold rules, evaluated every minute with cooldowns: */
rules: {
errorRatePercent: 5, // 5xx rate over the window
slowRouteMs: 2000, // route average duration
jobsFailed: 3, // failed jobs in the window
windowMinutes: 5,
cooldownMinutes: 15,
},
},
})Export to Monocle (or any OTLP backend)
Telescope is local-first, but everything it captures can be shipped to Monocle — the AdonisJS observability platform — or any OTLP/HTTP-compatible backend, as proper OpenTelemetry traces + logs (requests/jobs become root spans, queries/HTTP calls become child spans, everything else becomes correlated log records).
Zero config: set MONOCLE_API_KEY in the environment and entries
start flowing. Or tune it:
export default defineConfig({
export: {
monocle: {
apiKey: env.get('MONOCLE_API_KEY'),
// endpoint: 'https://ingest.monocle.sh', // any OTLP/HTTP backend
},
// …or ship batches anywhere yourself:
// sink: async (entries) => myPipeline.push(entries),
},
})Browser (client) errors
A public, rate-limited endpoint ingests frontend errors. Drop this in your app shell:
window.addEventListener('error', (event) => {
const payload = {
message: event.message,
name: event.error?.name,
stack: event.error?.stack,
url: location.href,
}
navigator.sendBeacon?.(
'/telescope/client-errors?e=' +
btoa(JSON.stringify(payload)).replace(/\+/g, '-').replace(/\//g, '_')
)
})They show up as client_error entries with stack traces.
telescope.dump()
The dd() of telescope — from anywhere in your code:
import telescope from '@dudousxd/adonis-telescope/services/main'
telescope.dump({ cart, totals }, 'checkout-debug')Shows up on the dashboard, correlated to the active request.
Custom watchers
Anything the built-ins don't cover can be captured with the same API the built-in watchers use:
import type { TelescopeWatcher, WatcherContext } from '@dudousxd/adonis-telescope/types'
class StripeWatcher implements TelescopeWatcher {
readonly type = 'stripe' // shows up automatically in the dashboard
async register(ctx: WatcherContext) {
const emitter = await ctx.app.container.make('emitter')
emitter.on('stripe:webhook', (payload) => {
ctx.record({
type: 'stripe',
content: { event: payload.type, id: payload.id },
tags: [`event:${payload.type}`],
})
})
}
}
export default defineConfig({
custom: [new StripeWatcher()],
})The WatcherContext also exposes startBatch(origin) / runInBatch()
for entry-point watchers (things that should group sub-entries, the way
requests and jobs do), and currentBatch() for correlation.
Configuration reference
Everything works without a config file. Create config/telescope.ts
only when you want to tune things:
import { defineConfig } from '@dudousxd/adonis-telescope'
export default defineConfig({
enabled: true, // default: !app.inProduction
path: 'telescope', // dashboard mount path
maxEntries: 10_000, // in-memory ring buffer capacity
ignorePaths: ['/health'], // never record these requests
redact: { keys: ['ssn'] }, // extra sensitive keys (merged with defaults)
watchers: {
queries: { slowMs: 150, nPlusOneThreshold: 5 },
logs: { minLevel: 'info' },
exceptions: { capture4xx: false },
requests: { recordResponse: true, slowMs: 1000 },
events: { ignore: [/^internal:/] },
// any watcher: false to disable
// validation / throttle / models / bouncer / transmit / commands: on by default
},
resolveUser: (ctx) => ctx.auth?.user,
})Safety
- Recording is exception-safe: a failing watcher can never break your app.
- Telescope's own work (storage writes, webhooks, AI calls) is flagged internally so it never records itself.
- Sensitive keys (
password,authorization,cookie, tokens, …) are redacted before storage; payloads are size-bounded and detached from live object graphs. EXPLAINnever usesANALYZE— captured queries are never re-executed.- The telescope routes themselves are never recorded.
