@adonis-agora/telescope 0.8.3 → 0.8.5

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,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.
@@ -0,0 +1,317 @@
1
+ ---
2
+ name: telescope-watchers
3
+ description: >-
4
+ Capture sources with @adonis-agora/telescope — built-in request/diagnostics/logs
5
+ watchers, the @adonis-agora/telescope/watchers subpath (LucidQueryWatcher db:query,
6
+ MailWatcher mail:sent, CacheWatcher cache:* events, HttpClientWatcher fetch wrap,
7
+ QueueWatcher boringqueue.job.execute, EventsWatcher onAny, RedisWatcher
8
+ sendCommand, profile()/startProfile(), scheduleTask()/registerSchedule()), and
9
+ custom watchers via the Watcher contract + safeRecord + currentTraceId. Use for
10
+ "record SQL queries", "watch outbound HTTP", "no queries recorded", "write a
11
+ custom watcher", "correlate entries to a trace", "AdonisJS telemetry".
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:docs/concepts/capture.mdx"
20
+ - "DavideCarvalho/adonis-telescope:docs/packages/watchers.mdx"
21
+ - "DavideCarvalho/adonis-telescope:docs/recipes/custom-watcher.mdx"
22
+ - "DavideCarvalho/adonis-telescope:packages/core/src/watchers/index.ts"
23
+ ---
24
+
25
+ # Watchers & capture
26
+
27
+ The core records requests, exceptions and diagnostics automatically. Every other
28
+ source — Lucid SQL, outbound fetch, mail, cache, queue jobs, events, Redis, logs,
29
+ timing spans, scheduled tasks — comes from a watcher. Per-technology watchers live
30
+ in the `@adonis-agora/telescope/watchers` subpath of the same package; nothing extra
31
+ to install.
32
+
33
+ ## Setup
34
+
35
+ Re-run configure and pick **Watchers**, then tune the published config:
36
+
37
+ ```sh
38
+ node ace configure @adonis-agora/telescope # pick "Watchers" at the prompt
39
+ ```
40
+
41
+ ```ts
42
+ // config/telescope_watchers.ts
43
+ import { defineConfig } from '@adonis-agora/telescope/watchers'
44
+
45
+ export default defineConfig({
46
+ enabled: true,
47
+ watchers: ['query'], // only the verified Lucid query watcher is on by default
48
+ query: { slowMs: 500, captureBindings: false, normalize: true },
49
+ httpClient: { slowMs: 1000, ignoreHosts: [], captureBodies: false }, // body SIZES, never bytes
50
+ })
51
+ ```
52
+
53
+ Enable more watchers by adding names: `'query'`, `'mail'`, `'cache'`,
54
+ `'http-client'`, `'logs'`, `'queue'`, `'events'`, `'redis'`, `'profiling'`,
55
+ `'schedule'`, `'queue-manager'`.
56
+
57
+ Source: `docs/packages/watchers.mdx` (Configuration).
58
+
59
+ ## Core patterns
60
+
61
+ ### Pattern 1 — record every Lucid query
62
+
63
+ The query watcher subscribes to Lucid's `db:query` event. Entries group by a
64
+ normalized SQL template hash (`select * from users where id = ?`), so every
65
+ execution of the same statement rolls into one family for `topFamilies()`.
66
+
67
+ ```ts
68
+ // config/telescope_watchers.ts
69
+ export default defineConfig({
70
+ watchers: ['query'],
71
+ // Bindings carry PII/secrets: redacted to [REDACTED] placeholders unless you opt in.
72
+ query: { slowMs: 500, captureBindings: false, normalize: true },
73
+ // ignoreConnections: ['telescope'] — drop e.g. telescope's own lucid-store writes.
74
+ })
75
+ ```
76
+
77
+ Lucid only **emits** `db:query` when the connection's `debug` flag is on or a
78
+ listener exists at report time; subscribing the watcher is enough to make Lucid
79
+ report, but `debug: true` in your DB config guarantees it:
80
+
81
+ ```ts
82
+ // config/database.ts
83
+ export const databaseConfig = defineConfig({
84
+ connections: {
85
+ pg: {
86
+ connection: { host: '127.0.0.1', database: 'app', user: 'postgres', password: 'secret' },
87
+ debug: true, // guarantee db:query flows even before any other listener attaches
88
+ },
89
+ },
90
+ })
91
+ ```
92
+
93
+ Source: `docs/packages/watchers.mdx` (Query watcher callouts),
94
+ `docs/getting-started.mdx` Step 5.
95
+
96
+ ### Pattern 2 — user-instrumented spans and scheduled runs
97
+
98
+ `profile()`/`startProfile()` and `scheduleTask()`/`registerSchedule()` are helpers
99
+ you call around code — nothing is captured until the watcher is enabled AND a
100
+ helper runs. While disabled they are zero-cost no-ops that still execute your
101
+ closure.
102
+
103
+ ```ts
104
+ import {
105
+ profile,
106
+ scheduleTask,
107
+ registerSchedule,
108
+ } from '@adonis-agora/telescope/watchers'
109
+
110
+ // Timing span — times fn, records completed/failed, re-throws on error.
111
+ const total = await profile('checkout', async (span) => {
112
+ span.mark('cart-loaded')
113
+ return await charge(cart)
114
+ })
115
+
116
+ // Scheduled run — wraps the closure you actually schedule.
117
+ await scheduleTask('prune-sessions', () => sessions.pruneExpired(), {
118
+ schedule: '0 * * * *',
119
+ kind: 'cron',
120
+ })
121
+
122
+ // Register what EXISTS so the dashboard can compute next-run
123
+ // (optional cron-parser peer; without it nextRunAt is null, not an error).
124
+ registerSchedule({ name: 'prune-sessions', schedule: '0 * * * *', kind: 'cron' })
125
+ ```
126
+
127
+ Source: `docs/packages/watchers.mdx` (Profiling watcher, Schedule watcher).
128
+
129
+ ### Pattern 3 — a custom watcher
130
+
131
+ Implement `{ type, start(emitter), stop() }` over the structural `EmitterLike`
132
+ (`on` returns an unsubscribe function). Record through `safeRecord(input, source)`:
133
+ it resolves the runtime store, backfills `currentTraceId()`, and swallows every
134
+ failure so your watcher cannot break the path it observes.
135
+
136
+ ```ts
137
+ // app/telescope/queue_watcher.ts
138
+ import { EntryType, type RecordInput, currentTraceId } from '@adonis-agora/telescope'
139
+ import { safeRecord, type Watcher, type EmitterLike } from '@adonis-agora/telescope/watchers'
140
+
141
+ export class QueueWatcher implements Watcher {
142
+ readonly type = EntryType.Job
143
+ private unsubscribe: (() => void) | null = null
144
+
145
+ start(emitter: EmitterLike): void {
146
+ if (this.unsubscribe) return // idempotent
147
+ this.unsubscribe = emitter.on('queue:processed', (data) => this.handle(data))
148
+ }
149
+
150
+ stop(): void {
151
+ this.unsubscribe?.()
152
+ this.unsubscribe = null
153
+ }
154
+
155
+ private handle(data: unknown): void {
156
+ if (!isQueueEvent(data)) return // tolerate junk on the channel
157
+ safeRecord(buildJobEntry(data), 'QueueWatcher') // guarded; never throws
158
+ }
159
+ }
160
+
161
+ function isQueueEvent(d: unknown): d is { queue: string; jobId: string; durationMs: number; failed: boolean } {
162
+ return typeof d === 'object' && d !== null && 'queue' in d && 'jobId' in d
163
+ }
164
+
165
+ function buildJobEntry(e: { queue: string; jobId: string; durationMs: number; failed: boolean }): RecordInput {
166
+ const traceId = currentTraceId()
167
+ return {
168
+ type: EntryType.Job,
169
+ familyHash: e.queue, // topFamilies(10, 'job') rolls up here
170
+ durationMs: e.durationMs,
171
+ traceId,
172
+ tags: [`queue:${e.queue}`, ...(e.failed ? ['failed'] : [])],
173
+ content: { queue: e.queue, jobId: e.jobId, failed: e.failed, durationMs: e.durationMs },
174
+ }
175
+ }
176
+ ```
177
+
178
+ Start it yourself in a provider (`boot()` → resolve `'emitter'` from the container
179
+ → `watcher.start(emitter)`; `shutdown()` → `stop()`). The watchers config controls
180
+ only the built-ins — a custom watcher is just a class you start.
181
+
182
+ Source: `docs/recipes/custom-watcher.mdx`.
183
+
184
+ ### Pattern 4 — tag entries by tenant/user
185
+
186
+ `getContextAccessor()` reads `@adonis-agora/context` structurally; add tags at
187
+ record time so `list({ tag })` pivots on them.
188
+
189
+ ```ts
190
+ import { getContextAccessor, type RecordInput } from '@adonis-agora/telescope'
191
+
192
+ export function withTenantTag<T>(input: RecordInput<T>): RecordInput<T> {
193
+ const tenant = getContextAccessor()?.tenantId()
194
+ if (tenant === undefined) return input
195
+ return { ...input, tags: [...(input.tags ?? []), `tenant:${tenant}`] }
196
+ }
197
+ ```
198
+
199
+ Source: `docs/recipes/tags-and-redaction.mdx`, `docs/recipes/request-context.mdx`.
200
+
201
+ ## Common mistakes
202
+
203
+ ### CRITICAL Enabling the query watcher without Lucid debug
204
+
205
+ ```ts
206
+ // Wrong — the watcher is listed, yet zero queries appear.
207
+ export default defineConfig({ watchers: ['query'] })
208
+ // config/database.ts has no debug flag and no other db:query listener exists.
209
+ ```
210
+
211
+ ```ts
212
+ // Correct — turn on debug for the connection (or attach another db:query listener).
213
+ pg: { /* ... */ debug: true }
214
+ ```
215
+
216
+ Mechanism: Lucid builds the `db:query` event only when `debug` is on or a listener
217
+ exists at report time — otherwise the event is never even constructed, so the
218
+ enabled watcher silently records nothing while looking healthy.
219
+ Source: `docs/packages/watchers.mdx` (Query watcher warn callout),
220
+ `docs/getting-started.mdx` Step 5.
221
+
222
+ ### HIGH Enabling the logs watcher in both config files
223
+
224
+ ```ts
225
+ // Wrong — 'logs' in config/telescope.ts AND config/telescope_watchers.ts.
226
+ // config/telescope.ts
227
+ export default defineConfig({ watchers: ['request', 'diagnostics', 'logs'], logs: { minLevel: 'warn' } })
228
+ // config/telescope_watchers.ts
229
+ export default defineConfig({ watchers: ['query', 'logs'] })
230
+ ```
231
+
232
+ ```ts
233
+ // Correct — enable it in exactly one place.
234
+ export default defineConfig({ watchers: ['request', 'diagnostics', 'logs'], logs: { minLevel: 'warn' } })
235
+ export default defineConfig({ watchers: ['query'] })
236
+ ```
237
+
238
+ Mechanism: whichever provider boots first owns the logger tap; the other warns and
239
+ stays inert, so its `minLevel`/`tags` options are silently ignored.
240
+ Source: `docs/reference/configuration.mdx` ('logs' warn callout).
241
+
242
+ ### HIGH Building entry content outside safeRecord
243
+
244
+ ```ts
245
+ // Wrong — content building runs on the HOST thread; one circular payload breaks
246
+ // the very operation being observed.
247
+ emitter.on('queue:processed', (d) => {
248
+ safeRecord({ type: EntryType.Job, content: JSON.parse(JSON.stringify(d.raw)) }, 'QueueWatcher')
249
+ })
250
+ ```
251
+
252
+ ```ts
253
+ // Correct — validate defensively, build plain content, let safeRecord guard the store.
254
+ private handle(data: unknown): void {
255
+ if (!isQueueEvent(data)) return
256
+ safeRecord(buildJobEntry(data), 'QueueWatcher')
257
+ }
258
+ ```
259
+
260
+ Mechanism: recording paths are fire-and-forget and fully guarded — a missing store,
261
+ a throwing `record`, or a rejected promise is swallowed and warn-logged, never
262
+ thrown into the request/job/query being watched.
263
+ Source: `docs/concepts/capture.mdx` (warn callout), `docs/recipes/custom-watcher.mdx`.
264
+
265
+ ### MEDIUM Non-idempotent start/leaky stop in a custom watcher
266
+
267
+ ```ts
268
+ // Wrong — double-subscribe duplicates every entry; a leaked subscription keeps
269
+ // recording after shutdown.
270
+ start(emitter: EmitterLike): void {
271
+ emitter.on('queue:processed', this.handle)
272
+ }
273
+ stop(): void {}
274
+ ```
275
+
276
+ ```ts
277
+ // Correct — keep the unsubscribe fn; guard re-entry; release in stop().
278
+ start(emitter: EmitterLike): void {
279
+ if (this.unsubscribe) return
280
+ this.unsubscribe = emitter.on('queue:processed', this.handle)
281
+ }
282
+ stop(): void {
283
+ this.unsubscribe?.()
284
+ this.unsubscribe = null
285
+ }
286
+ ```
287
+
288
+ Mechanism: `emitter.on` returns an unsubscribe function; a watcher that leaks it
289
+ records entries into a torn-down process and double-starts duplicate everything.
290
+ Source: `docs/recipes/custom-watcher.mdx` (warn callout).
291
+
292
+ ### MEDIUM Waiting on queue/redis watchers whose engines were never installed
293
+
294
+ ```ts
295
+ // Wrong — assuming pnpm installed the engines because the watcher names are valid.
296
+ export default defineConfig({ watchers: ['queue', 'redis'] })
297
+ // package.json has no @adonisjs/queue / @adonisjs/redis entry.
298
+ ```
299
+
300
+ ```json
301
+ // Correct — add the packages yourself; they are NOT declared peers.
302
+ {
303
+ "dependencies": {
304
+ "@adonisjs/queue": "^...",
305
+ "@adonisjs/redis": "^..."
306
+ }
307
+ }
308
+ ```
309
+
310
+ Mechanism: those two are deliberately not declared peers — their watchers tap a
311
+ Node diagnostics channel / wrap a manager they're handed, idle when nobody feeds
312
+ them, and npm will never install them for you.
313
+ Source: `docs/packages/watchers.mdx` (Peer dependencies).
314
+
315
+ See also: `telescope-storage-retention/SKILL.md` — where captured entries land and
316
+ how bounds/sampling shape what survives;
317
+ `telescope-alerts-ai/SKILL.md` — rules only ever see what was recorded.