Tags & redaction
Shape what Telescope captures — how tags and family hashes are assigned, how to add your own tags and grouping, how to redact sensitive values before they're recorded, and how to drop or sample noisy entries with a wrapping store.
Telescope records a lot by default. This guide is about controlling it: adding searchable tags, scrubbing secrets before they hit the store, and dropping or sampling noise. Redaction is on by default — a config-driven, central scrubber masks sensitive keys in every entry before it is persisted — and you can extend it, or shape capture further at the two points you control: the watcher that builds an entry, and the store it records into.
How tags and grouping work today
Every built-in watcher assigns tags and a familyHash from the event it sees:
| Entry | Tags | familyHash |
|---|---|---|
request | method:<M>, status:<code> | (none) |
exception | exception:<name>, method:<M> | name:message:topFrame |
diagnostic | lib:<lib>, event:<event>, trace:<id> | lib:event |
query | connection:<c>, method:<m>, model:<model> | SQL template hash |
mail | mailer:<name> | mailer name |
cache | cache:<op>, store:<store> | op:key |
Tags are what list({ tag }) and the dashboard search filter on; familyHash is what
topFamilies() rolls up. You add your own by recording your own entries.
Add custom tags — tag by tenant
A custom watcher (or any recordException /
manual record call) can add whatever tags you want. To pivot Telescope on tenant, include
a tenant:<id> tag when you record:
import { EntryType, type RecordInput, currentTraceId } from '@adonis-agora/telescope'
import { getContextAccessor } from '@adonis-agora/telescope'
export function withTenantTag<T>(input: RecordInput<T>): RecordInput<T> {
const tenant = getContextAccessor()?.tenantId()
if (tenant === undefined) return input
return { ...input, tags: [...(input.tags ?? []), `tenant:${tenant}`] }
}How it works
getContextAccessor() is the structural reader for @adonis-agora/context — the
same slot the store reads traceId from. It exposes tenantId(), userRef() and get()
too. Adding a tenant:<id> tag at record time makes list({ tag: 'tenant:acme' }) and the
dashboard's tag filter answer "everything that happened for this tenant".
await telescope.list({ tag: 'tenant:acme', type: 'exception' })Built-in redaction
Telescope ships a central, config-driven redaction scrubber that is on by default. It is
wired at the one boundary every watcher records through — the store's record() — so no
watcher can bypass it. Before any entry is persisted, its content is deep-cloned and every
sensitive key is masked with [REDACTED], matched case-insensitively at any depth:
authorization · cookie · set-cookie · password · passwd · secret · token
access_token · refresh_token · api-key · api_key · apikey · x-api-key
client_secret · private_keyThis covers the leak paths the built-in watchers had: the Lucid query watcher records bindings, the mail watcher records from/to/subject/context, and a custom watcher might record anything — all of them are scrubbed at the choke point.
Extend or disable it
Add your own keys (merged with the defaults), or turn it off entirely, in config/telescope.ts:
import { defineConfig } from '@adonis-agora/telescope'
export default defineConfig({
redact: {
enabled: true, // default; set false to persist content verbatim
keys: ['ssn', 'credit_card'], // extra keys, merged with the built-in set
},
})The clone is also memory-bounded and cycle-safe: it caps depth, string length, array
length, and total nodes/bytes so a pathological mega-payload (a hydrated ORM graph, a huge
base64 blob) can never balloon a single entry, and a self-referential object never throws —
it becomes [Circular].
Redact ad-hoc
The same redact() function is exported if you want to scrub a value yourself — e.g. before
building a custom entry's content:
import { redact } from '@adonis-agora/telescope'
safeRecord(
{ type: EntryType.Query, content: { sql, bindings: redact(bindings) } },
'MyQueryWatcher',
)This is rarely needed — the central scrubber already runs on every entry — but it lets a custom watcher mask before recording (e.g. so the pre-redaction value never lives in memory).
Sampling is already built in
Before writing any of the code below: if all you want is "record less", the sampling key on
config/telescope.ts does it with no code at all, on the same write path, with tail-sampling
built in.
export default defineConfig({
sampling: {
default: 1, // keep everything by default
cache: 0.1, // …but only a tenth of cache entries
query: { rate: 0.05, keepErrors: true, keepSlowMs: 500 },
},
})A bare number is a uniform keep-rate for every type; a per-type map lets each type carry its own
rate, with the reserved default key covering the rest. A rule object is the tail-sampling form:
keep rate of the ordinary traffic but always retain what matters — anything that looks like
an error (keepErrors) and anything slower than keepSlowMs. sampling: 0.1 is the whole
config for "keep 10% of everything".
A sampled-out entry is never persisted, and the dashboard's retention indicator tells viewers which types are recording below 100% so nobody reads a sampled count as the real one.
Reach for a custom store only when the decision needs something sampling can't express — a per-URL rule, a lookup, a rewrite.
Drop or sample noise with a wrapping store
To filter, rewrite or drop across all watchers on a rule sampling cannot express, wrap your
real store and decide in record whether to persist. Because everything records through the one
TelescopeStore, this is the single choke point.
import type { TelescopeStore, Entry, RecordInput, EntryQuery } from '@adonis-agora/telescope'
export class FilteringStore implements TelescopeStore {
constructor(private readonly inner: TelescopeStore) {}
async record<T>(input: RecordInput<T>): Promise<Entry<T>> {
if (this.shouldDrop(input)) return this.synthetic(input) // never persisted
if (this.shouldSample(input)) return this.synthetic(input) // sampled out
return this.inner.record(input)
}
private shouldDrop(input: RecordInput<unknown>): boolean {
// Drop health-check request noise.
const url = (input.content as { url?: string } | undefined)?.url
return input.type === 'request' && url === '/health'
}
private shouldSample(input: RecordInput<unknown>): boolean {
// Keep 10% of cache hits; keep everything else.
return input.tags?.includes('cache:hit') === true && Math.random() > 0.1
}
// delegate the rest unchanged
get(id: string) { return this.inner.get(id) }
list(q?: EntryQuery) { return this.inner.list(q) }
count() { return this.inner.count() }
prune(d: Date, k?: number) { return this.inner.prune(d, k) }
clear() { return this.inner.clear() }
private synthetic<T>(input: RecordInput<T>): Entry<T> {
return { id: 'dropped', type: input.type, familyHash: input.familyHash ?? null,
content: input.content, tags: input.tags ?? [], sequence: -1, durationMs: input.durationMs ?? null,
origin: 'manual', traceId: input.traceId ?? null, createdAt: new Date() }
}
}import { defineConfig, InMemoryTelescopeStore } from '@adonis-agora/telescope'
import { FilteringStore } from '#telescope/filtering_store'
export default defineConfig({
store: new FilteringStore(new InMemoryTelescopeStore({ maxEntries: 5000 })),
})How it works
record is the only write path, so wrapping it lets you drop (health checks, static
assets), sample (high-volume cache hits), or rewrite (redact) uniformly — without touching a
single watcher. Return a synthetic, non-persisted Entry for dropped/sampled inputs so the
caller's await store.record(...) still resolves (watchers ignore the result anyway).
If your shouldSample is turning into "keep everything that errored, sample the rest", that is
exactly what the built-in sampling config's
{ rate, keepErrors, keepSlowMs } rules do — and they compose with the pruner and the dashboard's
retention indicator, which a hand-rolled store does not. Keep the wrapping store for the rules
sampling genuinely cannot express, like dropping one specific URL.
Building an extension
Step-by-step — package a sibling library's observability into a Telescope extension that contributes a navigable entry type, server-side data providers, and a declarative dashboard page, then register it in config — with no React and nothing Telescope-internal.
Request context
Correlate Telescope entries to a request, a trace, a tenant, and a user via @adonis-agora/context — how trace correlation works, how to tag entries with the authenticated user, and how to reconstruct everything that happened on one trace.