Agora
Guides

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:

EntryTagsfamilyHash
requestmethod:<M>, status:<code>(none)
exceptionexception:<name>, method:<M>name:message:topFrame
diagnosticlib:<lib>, event:<event>, trace:<id>lib:event
queryconnection:<c>, method:<m>, model:<model>SQL template hash
mailmailer:<name>mailer name
cachecache:<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:

app/telescope/tenant_tags.ts
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_key

This 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:

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.

config/telescope.ts
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.

app/telescope/filtering_store.ts
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() }
  }
}
config/telescope.ts
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.

On this page