@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,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.