@adonis-agora/telescope 0.8.4 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,255 @@
1
+ ---
2
+ name: telescope-setup
3
+ description: >-
4
+ Set up @adonis-agora/telescope in an AdonisJS app. Covers node ace configure
5
+ codemods (telescope_provider + TelescopeMiddleware on the server stack +
6
+ config/telescope.ts), defineConfig keys (enabled/store/stores/watchers/redact),
7
+ the storage.memory and storage.lucid drivers, the headless TelescopeService query
8
+ API (list/find/byTrace/count/topFamilies/topTags/getHealth), recordException for
9
+ non-HTTP paths, opt-in requestCapture body gates, and why /telescope 404s without
10
+ @adonis-agora/telescope-ui. Use for "install telescope", "telemetry is empty",
11
+ "query captured entries", "capture request bodies", "AdonisJS observability".
12
+ license: MIT
13
+ metadata:
14
+ type: core
15
+ library: "@adonis-agora/telescope"
16
+ library_version: "0.8.4"
17
+ framework: adonisjs
18
+ sources:
19
+ - "DavideCarvalho/adonis-telescope:README.md"
20
+ - "DavideCarvalho/adonis-telescope:docs/getting-started.mdx"
21
+ - "DavideCarvalho/adonis-telescope:docs/packages/core.mdx"
22
+ - "DavideCarvalho/adonis-telescope:docs/reference/configuration.mdx"
23
+ ---
24
+
25
+ # Setting up @adonis-agora/telescope
26
+
27
+ `@adonis-agora/telescope` is Laravel Telescope-style **headless** observability for
28
+ AdonisJS: it records every inbound HTTP request, every unhandled exception and every
29
+ `agora:<lib>:<event>` diagnostics publish as a queryable `Entry`. The core ships no
30
+ page — mounting a UI is a separate package (`telescope-ui-dashboard` skill).
31
+
32
+ ## Setup
33
+
34
+ ```sh
35
+ pnpm add @adonis-agora/telescope
36
+ node ace configure @adonis-agora/telescope
37
+ ```
38
+
39
+ `configure` runs codemods that register `@adonis-agora/telescope/telescope_provider`
40
+ in `adonisrc.ts`, plug `TelescopeMiddleware` onto the **server** middleware stack
41
+ (so it wraps the whole pipeline and observes the final status), and publish
42
+ `config/telescope.ts`. That alone records `request` + `diagnostic` entries.
43
+
44
+ The published config is all-defaults:
45
+
46
+ ```ts
47
+ // config/telescope.ts
48
+ import { defineConfig, storage } from '@adonis-agora/telescope'
49
+
50
+ export default defineConfig({
51
+ enabled: true, // master switch
52
+ store: 'memory', // which driver in `stores` is active
53
+ stores: {
54
+ memory: storage.memory({ limit: 1000 }), // bounded ring buffer, lost on restart
55
+ // lucid: storage.lucid({ connection: 'pg' }), // persistent SQL store (optional peer)
56
+ },
57
+ watchers: ['request', 'diagnostics'], // omit one to disable it
58
+ })
59
+ ```
60
+
61
+ Read entries back from anywhere via the container:
62
+
63
+ ```ts
64
+ import { TelescopeService } from '@adonis-agora/telescope'
65
+
66
+ const telescope = await app.container.make(TelescopeService)
67
+
68
+ await telescope.list({ type: 'request', limit: 50 }) // newest-first
69
+ await telescope.byTrace('abc123') // one request's whole story
70
+ await telescope.list({ tag: 'lib:billing', search: 'invoice' })
71
+ await telescope.topFamilies(10, 'diagnostic') // busiest lib:event pairs
72
+ ```
73
+
74
+ ## Core patterns
75
+
76
+ ### Pattern 1 — the headless API inside a controller
77
+
78
+ Controllers have no `app` of their own; use `@inject()` so the container resolves
79
+ the constructor dependency.
80
+
81
+ ```ts
82
+ // app/controllers/inspector_controller.ts
83
+ import { inject } from '@adonisjs/core'
84
+ import type { HttpContext } from '@adonisjs/core/http'
85
+ import { TelescopeService } from '@adonis-agora/telescope'
86
+
87
+ @inject()
88
+ export default class InspectorController {
89
+ constructor(private telescope: TelescopeService) {}
90
+
91
+ async index({ response }: HttpContext) {
92
+ return response.json(await this.telescope.list({ limit: 50 }))
93
+ }
94
+ }
95
+ ```
96
+
97
+ `EntryQuery` composes filters with AND: `{ type, tag, familyHash, traceId, before,
98
+ after, search, limit }`. `search` matches JSON content **and** tags, so a request
99
+ matches by url, a diagnostic by event name, a query by SQL — all from one box.
100
+ For React usage in a custom console, see the `telescope-ui-dashboard` skill.
101
+
102
+ Source: `docs/getting-started.mdx` (Step 3).
103
+
104
+ ### Pattern 2 — manual exception capture outside HTTP
105
+
106
+ Queue workers, ace commands and `app/exceptions/handler.ts` are not wrapped by the
107
+ middleware. `recordException` reads the live store with nothing injected, is a no-op
108
+ when Telescope is off, and never throws.
109
+
110
+ ```ts
111
+ // app/exceptions/handler.ts
112
+ import { recordException } from '@adonis-agora/telescope'
113
+
114
+ async report(error: unknown, ctx: HttpContext) {
115
+ recordException(error, { method: ctx.request.method(), url: ctx.request.url() })
116
+ return super.report(error, ctx)
117
+ }
118
+ ```
119
+
120
+ Exceptions group by `exceptionFamilyHash` = `name:message:topStackFrame`, so alerts
121
+ and the dashboard's exception groups stay deterministic across processes.
122
+
123
+ Source: `docs/concepts/capture.mdx` (Manual capture), `docs/packages/core.mdx`.
124
+
125
+ ### Pattern 3 — opt-in request-body capture
126
+
127
+ By default a `request` entry has **no `body` field at all**. Turn it on with gates
128
+ that run before redaction; a rejected body becomes a marker string like
129
+ `[Skipped: 200000 bytes > 131072 bytes]`.
130
+
131
+ ```ts
132
+ // config/telescope.ts
133
+ export default defineConfig({
134
+ requestCapture: {
135
+ maxBodyBytes: 65_536,
136
+ skipBodyContentTypes: ['multipart/form-data'],
137
+ skipBody: ({ url }) => url.startsWith('/webhooks/'),
138
+ },
139
+ })
140
+ ```
141
+
142
+ Captured bodies still pass through central redaction — but a body never captured is
143
+ the only body that definitely cannot leak.
144
+
145
+ Source: `docs/packages/core.mdx` (Request body capture).
146
+
147
+ ### Pattern 4 — production storage
148
+
149
+ Switch to the built-in Lucid driver when entries must survive restarts (see the
150
+ `telescope-storage-retention` skill for the migration and pruning):
151
+
152
+ ```sh
153
+ pnpm add @adonisjs/lucid
154
+ node ace migration:run # create_telescope_entries_table, shipped by configure
155
+ ```
156
+
157
+ ```ts
158
+ export default defineConfig({
159
+ store: 'lucid',
160
+ stores: { lucid: storage.lucid() }, // or storage.lucid({ connection: 'pg' })
161
+ })
162
+ ```
163
+
164
+ Source: `docs/getting-started.mdx` (Step 4).
165
+
166
+ ## Common mistakes
167
+
168
+ ### HIGH Expecting a dashboard page from the core alone
169
+
170
+ ```ts
171
+ // Wrong — nothing mounts a page; http://localhost:3333/telescope answers 404.
172
+ // adonisrc.ts
173
+ providers: [
174
+ () => import('@adonis-agora/telescope/telescope_provider'),
175
+ // ...app providers
176
+ ]
177
+ ```
178
+
179
+ ```ts
180
+ // Correct — install @adonis-agora/telescope-ui and register its provider AFTER ui_provider.
181
+ providers: [
182
+ () => import('@adonis-agora/telescope/telescope_provider'),
183
+ () => import('@adonis-agora/telescope/ui_provider'), // JSON API + SSE
184
+ () => import('@adonis-agora/telescope-ui/telescope_ui_dashboard_provider'), // the SPA
185
+ ]
186
+ ```
187
+
188
+ Mechanism: the core `ui` subpath serves only `<path>/api/*`; the prefix root belongs
189
+ to the separate SPA package, whose provider must also be registered after it.
190
+ Source: `docs/packages/ui.mdx` (Install), `docs/packages/telescope-ui.mdx` (warn).
191
+
192
+ ### HIGH Assuming request bodies are captured by default
193
+
194
+ ```ts
195
+ // Wrong — agents assume they can read the payload that broke checkout:
196
+ const entry = await telescope.find(id)
197
+ entry.content.body // undefined — bodies are opt-in
198
+ ```
199
+
200
+ ```ts
201
+ // Correct — enable requestCapture explicitly (gates run before redaction).
202
+ defineConfig({
203
+ requestCapture: { maxBodyBytes: 65_536 },
204
+ })
205
+ ```
206
+
207
+ Mechanism: the right default keeps passwords/card numbers out of storage; the entry
208
+ records method/url/status/duration only until you opt in per environment.
209
+ Source: `docs/packages/core.mdx` (Request body capture).
210
+
211
+ ### HIGH Hand-rolling the provider without the server-stack middleware
212
+
213
+ ```ts
214
+ // Wrong — provider registered but TelescopeMiddleware missing: no requests recorded,
215
+ // no exceptions captured, and nothing warns about it.
216
+ providers: [
217
+ () => import('@adonis-agora/telescope/telescope_provider'),
218
+ ]
219
+ ```
220
+
221
+ ```sh
222
+ # Correct — let the configure codemod wire both pieces.
223
+ node ace configure @adonis-agora/telescope
224
+ # verifies: telescope_provider present AND TelescopeMiddleware on the server stack
225
+ ```
226
+
227
+ Mechanism: capture comes from `TelescopeMiddleware` on the `server` middleware
228
+ stack; the provider alone starts only the boot-time watchers, so the timeline stays
229
+ empty while everything looks installed.
230
+ Source: `docs/reference/configuration.mdx` (Providers table), `docs/getting-started.mdx` Step 1.
231
+
232
+ ### MEDIUM Resolving TelescopeService via this.app inside a controller
233
+
234
+ ```ts
235
+ // Wrong — a controller has no `app`; this does not compile.
236
+ constructor(private app: ApplicationService) {}
237
+ async index() {
238
+ const telescope = await this.app.container.make(TelescopeService)
239
+ }
240
+ ```
241
+
242
+ ```ts
243
+ // Correct — constructor injection through @inject().
244
+ @inject()
245
+ export default class InspectorController {
246
+ constructor(private telescope: TelescopeService) {}
247
+ }
248
+ ```
249
+
250
+ Mechanism: `@inject()` asks the container to build the controller's dependencies
251
+ from their constructor types; `TelescopeService` arrives already resolved.
252
+ Source: `docs/getting-started.mdx` (Step 3 note).
253
+
254
+ See also: `telescope-ui-dashboard/SKILL.md` — mounting the page this setup lacks;
255
+ `telescope-storage-retention/SKILL.md` — swapping the dev-only memory driver.
@@ -0,0 +1,292 @@
1
+ ---
2
+ name: telescope-storage-retention
3
+ description: >-
4
+ Storage, retention, redaction and sampling in @adonis-agora/telescope — the
5
+ storage.memory ring buffer vs the storage.lucid SQL driver (optional peer,
6
+ create_telescope_entries_table migration), the TelescopeStore contract
7
+ (record/get/list/count/prune/clear), prune {after,keepLast,intervalMs}, the
8
+ overload guard + setTelescopePaused, redact {keys,perType}, tail-sampling rules
9
+ {rate,keepErrors,keepSlowMs}, and writing a custom store that resolves
10
+ traceId/origin. Use for "persistent storage", "entries lost on restart",
11
+ "table keeps growing", "redact secrets", "sample noisy entry types", "custom
12
+ store".
13
+ license: MIT
14
+ metadata:
15
+ type: core
16
+ library: "@adonis-agora/telescope"
17
+ library_version: "0.8.4"
18
+ framework: adonisjs
19
+ sources:
20
+ - "DavideCarvalho/adonis-telescope:docs/concepts/storage.mdx"
21
+ - "DavideCarvalho/adonis-telescope:docs/concepts/protection.mdx"
22
+ - "DavideCarvalho/adonis-telescope:docs/packages/storages.mdx"
23
+ - "DavideCarvalho/adonis-telescope:docs/recipes/custom-storage.mdx"
24
+ ---
25
+
26
+ # Storage, retention & protection
27
+
28
+ A store sits between capture and query: watchers `record` into it, and
29
+ `TelescopeService` reads out of it. Storage is **config-driven** — build named
30
+ drivers with the `storage` factory, pick one with `store`. Two ship in the box:
31
+ `memory` (bounded ring buffer) and `lucid` (persistent SQL via an optional
32
+ `@adonisjs/lucid` peer imported lazily only when selected).
33
+
34
+ ## Setup
35
+
36
+ ```ts
37
+ // config/telescope.ts
38
+ import { defineConfig, storage } from '@adonis-agora/telescope'
39
+
40
+ export default defineConfig({
41
+ store: 'lucid',
42
+ stores: {
43
+ memory: storage.memory({ limit: 5000 }), // dev/tests — per-process, lost on restart
44
+ lucid: storage.lucid(), // or storage.lucid({ connection: 'pg', tableName: 'tscope' })
45
+ },
46
+ // Retention pruner — OFF unless a prune block is present.
47
+ prune: { after: '24h', keepLast: 50_000, intervalMs: 60_000 },
48
+ // Event-loop overload guard (on by default): pauses ingestion when p99 lag >= maxEventLoopLagMs.
49
+ overload: { enabled: true, maxEventLoopLagMs: 200, startupGraceMs: 5_000 },
50
+ })
51
+ ```
52
+
53
+ The lucid driver needs its table; `node ace configure @adonis-agora/telescope`
54
+ publishes it next to the config:
55
+
56
+ ```sh
57
+ node ace migration:run # create_telescope_entries_table (+ indexes on created_at/type/trace_id/family_hash)
58
+ ```
59
+
60
+ Source: `docs/concepts/storage.mdx`, `docs/packages/storages.mdx`.
61
+
62
+ ## Core patterns
63
+
64
+ ### Pattern 1 — query the store through TelescopeService
65
+
66
+ Every set field of `EntryQuery` is an AND predicate; results are always
67
+ newest-first.
68
+
69
+ ```ts
70
+ import { TelescopeService } from '@adonis-agora/telescope'
71
+
72
+ const telescope = await app.container.make(TelescopeService)
73
+ await telescope.list({ type: 'query', search: 'users', limit: 50 })
74
+ await telescope.list({ tag: 'status:500', after: oneHourAgo })
75
+ await telescope.list({ familyHash: 'billing:invoice-paid' })
76
+ ```
77
+
78
+ Source: `docs/concepts/storage.mdx` (Querying).
79
+
80
+ ### Pattern 2 — redaction and per-type bounds
81
+
82
+ Redaction is **on by default** at the one boundary every watcher records through —
83
+ the store's `record()` — so no watcher can bypass it. Sensitive keys
84
+ (`authorization`, `cookie`, `password`, `token`, `secret`, …) are masked with
85
+ `[REDACTED]` case-insensitively at any depth; the clone is also memory-bounded and
86
+ cycle-safe.
87
+
88
+ ```ts
89
+ // config/telescope.ts
90
+ export default defineConfig({
91
+ redact: {
92
+ enabled: true,
93
+ keys: ['ssn', 'credit_card'], // merged with the built-in set
94
+ // perType raises only the NUMERIC bounds for one type — masking stays uniform:
95
+ perType: { exception: { maxContentBytes: 64_000 } },
96
+ },
97
+ })
98
+ ```
99
+
100
+ The same `redact()` function is exported to scrub a value before building custom
101
+ content yourself.
102
+
103
+ Source: `docs/reference/configuration.mdx` (redact callout),
104
+ `docs/recipes/tags-and-redaction.mdx` (Built-in redaction).
105
+
106
+ ### Pattern 3 — tail-sample noisy types without losing errors
107
+
108
+ `sampling` runs on the write path. A bare number is a uniform keep-rate; per-type
109
+ rules keep everything that looks like an error or is slow, no matter the rate.
110
+
111
+ ```ts
112
+ export default defineConfig({
113
+ sampling: {
114
+ default: 1, // keep everything by default
115
+ cache: 0.1, // a tenth of cache entries
116
+ request: { rate: 0.25, keepErrors: true, keepSlowMs: 500 }, // but all errors/slow
117
+ },
118
+ })
119
+ ```
120
+
121
+ Sampled-out entries are never persisted, and the dashboard's retention indicator
122
+ shows which types record below 100% so counts aren't misread.
123
+
124
+ Source: `docs/recipes/tags-and-redaction.mdx` (Sampling),
125
+ `docs/packages/ui.mdx` (Retention endpoint).
126
+
127
+ ### Pattern 4 — pause capture around bulk work
128
+
129
+ `setTelescopePaused(true)` flips the same flag the overload guard uses; every
130
+ ingestion point honours it immediately.
131
+
132
+ ```ts
133
+ import { setTelescopePaused } from '@adonis-agora/telescope'
134
+
135
+ setTelescopePaused(true)
136
+ try {
137
+ await importEverything()
138
+ } finally {
139
+ setTelescopePaused(false) // always restore: process-global, nothing else flips it back
140
+ }
141
+ ```
142
+
143
+ Source: `docs/concepts/protection.mdx` (Pausing on purpose).
144
+
145
+ ### Pattern 5 — a custom store
146
+
147
+ Implement the six-method contract and pass an instance as `store`. Two
148
+ responsibilities are easy to miss: `record` must resolve `traceId`/`origin`, and
149
+ `search` needs serialized content text.
150
+
151
+ ```ts
152
+ import {
153
+ currentTraceId, isBatchOrigin,
154
+ type BatchOrigin, type Entry, type EntryQuery,
155
+ type RecordInput, type TelescopeStore,
156
+ } from '@adonis-agora/telescope'
157
+ import { randomUUID } from 'node:crypto'
158
+
159
+ export class MongoTelescopeStore implements TelescopeStore {
160
+ async record<T>(input: RecordInput<T>): Promise<Entry<T>> {
161
+ const traceId = input.traceId !== undefined ? input.traceId : currentTraceId()
162
+ const origin: BatchOrigin = isBatchOrigin(input.origin) ? input.origin : 'manual'
163
+ const entry: Entry<T> = {
164
+ id: randomUUID(), type: input.type, familyHash: input.familyHash ?? null,
165
+ content: input.content, tags: input.tags ?? [], sequence: this.sequence++,
166
+ durationMs: input.durationMs ?? null, origin, traceId, createdAt: new Date(),
167
+ }
168
+ await this.col.insertOne({ ...entry, createdAtMs: entry.createdAt.getTime(),
169
+ contentText: JSON.stringify(entry.content) }) // search target
170
+ return entry
171
+ }
172
+ /* get / list / count / prune(olderThan, keepLast?) / clear */
173
+ }
174
+
175
+ // config/telescope.ts
176
+ export default defineConfig({ store: new MongoTelescopeStore(col) })
177
+ ```
178
+
179
+ Source: `docs/recipes/custom-storage.mdx`.
180
+
181
+ ## Common mistakes
182
+
183
+ ### HIGH Shipping the memory driver to production
184
+
185
+ ```ts
186
+ // Wrong — per-process ring buffer, lost on every restart/deploy; in a cluster each
187
+ // worker shows a different slice of reality.
188
+ defineConfig({ store: 'memory', stores: { memory: storage.memory({ limit: 1000 }) } })
189
+ ```
190
+
191
+ ```ts
192
+ // Correct — durable, cross-query storage via the built-in Lucid driver.
193
+ defineConfig({ store: 'lucid', stores: { lucid: storage.lucid() } })
194
+ ```
195
+
196
+ Mechanism: eviction past the cap drops the oldest entries and nothing survives a
197
+ restart, so "nothing here" after a deploy is indistinguishable from "nothing
198
+ happened".
199
+ Source: `docs/concepts/storage.mdx` (memory driver warn).
200
+
201
+ ### HIGH Switching to lucid without arming retention
202
+
203
+ ```ts
204
+ // Wrong — a persistent SQL table with no time-based bound grows forever.
205
+ defineConfig({ store: 'lucid', stores: { lucid: storage.lucid() } })
206
+ ```
207
+
208
+ ```ts
209
+ // Correct — supply a prune block; that is what arms the background pruner.
210
+ defineConfig({
211
+ store: 'lucid',
212
+ stores: { lucid: storage.lucid() },
213
+ prune: { after: '24h', keepLast: 50_000 },
214
+ })
215
+ ```
216
+
217
+ Mechanism: no store auto-prunes by age (the memory driver evicts by count, the
218
+ lucid store never auto-evicts because that would add a query per write); the
219
+ pruner exists only when a `prune` block is supplied.
220
+ Source: `docs/concepts/protection.mdx` (info callout), `docs/packages/storages.mdx` (warn).
221
+
222
+ ### HIGH Custom store skipping traceId/origin resolution
223
+
224
+ ```ts
225
+ // Wrong — traceId left undefined and origin unvalidated:
226
+ traceId: input.traceId, origin: input.origin as BatchOrigin,
227
+ ```
228
+
229
+ ```ts
230
+ // Correct — explicit null respected, omitted reads context, junk falls back:
231
+ const traceId = input.traceId !== undefined ? input.traceId : currentTraceId()
232
+ const origin: BatchOrigin = isBatchOrigin(input.origin) ? input.origin : 'manual'
233
+ ```
234
+
235
+ Mechanism: skipping this silently breaks trace correlation for everything
236
+ downstream (`byTrace`, waterfalls, N+1 detection) while every write still succeeds.
237
+ Source: `docs/recipes/custom-storage.mdx` (load-bearing lines).
238
+
239
+ ### MEDIUM Importing services/db inside a provider's boot()
240
+
241
+ ```ts
242
+ // Wrong — the facade default export is undefined until app.booted(), which runs
243
+ // AFTER provider boot.
244
+ import db from '@adonisjs/lucid/services/db'
245
+ const store = new LucidTelescopeStore(db)
246
+ ```
247
+
248
+ ```ts
249
+ // Correct — resolve the binding from the container during boot.
250
+ const db = await app.container.make('lucid.db')
251
+ const store = new LucidTelescopeStore(db, { tableName: 'telescope_entries' })
252
+ ```
253
+
254
+ Mechanism: provider boot precedes `app.booted()`; resolving lazily through the
255
+ container gets the live binding instead of `undefined`.
256
+ Source: `docs/packages/storages.mdx` (Programmatic API note).
257
+
258
+ ### MEDIUM Treating sampling as a security control
259
+
260
+ ```ts
261
+ // Wrong — assuming dropping 90% of queries hides the secrets in the other 10%.
262
+ sampling: { query: 0.1 }
263
+ ```
264
+
265
+ ```ts
266
+ // Correct — scrub at the store boundary (always on); sample only for retention cost.
267
+ redact: { keys: ['ssn'] },
268
+ sampling: { query: { rate: 0.1, keepErrors: true } },
269
+ ```
270
+
271
+ Mechanism: sampling decides what gets *stored*, not what gets *scrubbed*; redaction
272
+ runs on every recorded entry regardless of rate.
273
+ Source: `docs/packages/advanced.mdx` (sampling warn callout).
274
+
275
+ ### MEDIUM Misreading prune keepLast semantics
276
+
277
+ ```ts
278
+ // Wrong — assuming keepLast caps the WHOLE table at N rows.
279
+ await store.prune(cutoff, 500) // expected: table trimmed to newest 500 overall
280
+ ```
281
+
282
+ ```ts
283
+ // Correct — keepLast retains the newest N of the matched-and-doomed set:
284
+ const deleted = await store.prune(cutoff, 500) // deletes older-than-cutoff EXCEPT its newest 500
285
+ ```
286
+
287
+ Mechanism: the doomed set is computed first (everything older than cutoff), then
288
+ its newest `keepLast` survive; total row count can exceed `keepLast`.
289
+ Source: `docs/concepts/storage.mdx` (Retention and pruning).
290
+
291
+ See also: `telescope-watchers/SKILL.md` — the capture side these bounds protect;
292
+ `telescope-alerts-ai/SKILL.md` — why sampled-out exceptions never page.