@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.
- package/CHANGELOG.md +29 -0
- package/dist/src/ai/index.d.ts +1 -1
- package/dist/src/ai/index.js +1 -1
- package/dist/src/alerts/index.d.ts +1 -1
- package/dist/src/alerts/index.js +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/mcp/index.d.ts +1 -1
- package/dist/src/mcp/index.js +1 -1
- package/dist/src/profiling/index.d.ts +1 -1
- package/dist/src/profiling/index.js +1 -1
- package/dist/src/ui/index.d.ts +1 -1
- package/dist/src/ui/index.js +1 -1
- package/dist/src/watchers/index.d.ts +1 -1
- package/dist/src/watchers/index.js +1 -1
- package/dist/stubs/main.d.ts +6 -0
- package/dist/stubs/main.d.ts.map +1 -0
- package/dist/stubs/{main.ts → main.js} +1 -1
- package/dist/stubs/main.js.map +1 -0
- package/package.json +7 -4
- package/skills/telescope-access-mcp/SKILL.md +280 -0
- package/skills/telescope-alerts-ai/SKILL.md +290 -0
- package/skills/telescope-setup/SKILL.md +255 -0
- package/skills/telescope-storage-retention/SKILL.md +292 -0
- package/skills/telescope-watchers/SKILL.md +317 -0
|
@@ -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.
|