Udjat Observability
A request touches a route, a handful of queries, maybe a log line or an exception, and then it is gone — the only evidence any of it happened is whatever you thought to log in advance. Udjat is the restored eye that watches all of it as it happens: every query a request or a job ran, every exception it threw, every log line, mail, notification, and event it produced, correlated into the one batch that request or job caused.
Udjat is a Vizier extension, not a separate dashboard. One panel, one login, one layout — recorded entries sit next to the rest of the admin rather than on their own port behind their own password.
Udjat records everything it watches; nothing is filtered by tag
Laravel’s Telescope lets you attach closures that inspect an entry before
deciding whether to keep it. Udjat’s watchers config is a plain opt-in map
of booleans instead — a type either records or it does not, with no closure
in between. See Coming From Laravel.
Installing
dependencies: udjat: ^0.1.0maat udjat:installThat publishes the two views the panel renders. Then wire the three pieces:
final migrations = <Migration>[...udjatMigrations];
// bootstrap/app.dart — the recording halfApplication.configure(...).withExtensions([udjatExtension]).create();
// bootstrap/vizier_app.dart — the panel halfPanel(..., extensions: [udjatVizier()]);Two opt-ins rather than one: an application may want the recording without the panel, and the panel is the only half that needs Vizier.
The eight watchers
Every watcher is attached only when its config key is on — an unattached watcher costs the application nothing, which is what “disabled” has to mean in a long-lived process.
| Type | Seam | Notes |
|---|---|---|
request |
Global middleware, prepended ahead of everything else | Opens the batch every other watcher records into, including a 404 |
query |
Connection.listen on Seshat’s QueryEvent stream |
Bindings are stored verbatim — see Redaction |
exception |
ExceptionHandler.report |
Registered alongside the default logging reporter, never instead of it |
log |
Log.listener, chained onto whatever listener is already there |
A predecessor that throws cannot break the chain |
event |
Dispatcher.afterDispatch, chained the same way |
Skips events another watcher already records in a richer form (mail, notifications) |
mail |
Dispatcher.listen<MessageSent> |
Only the sent event — a listener may still veto a sending one, and a record claiming a mail went out when it did not is worse than no record |
notification |
Dispatcher.listen<NotificationSent>, <NotificationFailed> and <NotificationSkipped> |
Records delivery, failure, and a suppressed send |
job |
Khepri’s JobObservers, both as an observer and as a wrapper |
Only attached when the application actually binds JobObservers — an app with no queue never sees it |
The job watcher is the one seam that is not a plain listener: it wraps
Worker.runNext in a batch (JobObservers.around) so the job’s own queries
and log lines land inside that job’s batch, then records the outcome
(JobObservers.add) once the wrapped run finishes.
The entry type list is open
These eight are the watchers Udjat itself ships and attaches from
UdjatServiceProvider.boot(). UdjatEntryType names more types than that —
view and model exist alongside the eight above, with no watcher shipped
for either. They are there for another package to record into the same
udjat_entries store — a template-rendering or model-hydration count is
exactly the kind of thing an N+1 shows up in, and Udjat’s job is to be one
place that history lands, not to be the only thing that ever writes to it.
A consumer records one of these, or an entirely new type of its own, one of two ways:
- List it
truein thewatchersconfig map, if the type is oneUdjatEntryType.allalready names (viewandmodelqualify; a brand new string does not, since the config map is only ever built by walking that list). - Call
UdjatRecorder.watch('mytype')at boot time, which works for any string at all — this is the seam a consumer package reaches for when it wants a type Udjat’s own fixed list has never heard of.record()itself takes anyString type;watch()is what makes that type actually get kept rather than silently dropped.
Configuration
final udjat = { 'enabled': env('UDJAT_ENABLED') == 'true', 'watchers': {'request': true, 'query': true, 'log': true}, 'max_entries_per_batch': 200, 'prune_hours': 24, 'redact_headers': ['authorization', 'cookie'], 'redact_keys': ['password', 'token'], 'ignore_paths': ['/admin', '/_debugbar'],};| Key | Default | What it does |
|---|---|---|
enabled |
false |
Records nothing at all until this is true. |
watchers |
every type in UdjatEntryType.all |
Opt-in and authoritative. A type records only if the map lists it true. Omitting the watchers key entirely — not passing it at all — means every type in UdjatEntryType.all records; passing a map that omits a type turns that type off. There is no partial default: once you write a watchers map, you own every entry in it. UdjatEntryType.all presently has ten entries, not eight — the eight shipped watchers plus view and model (see above). An application that writes out only the eight it knows about gets exactly those eight; view and model are silently off, not silently on — the opposite of what omitting the key entirely does. |
max_entries_per_batch |
200 |
The most entries one request or job may buffer. Beyond this the batch counts and discards rather than growing, so one pathological request cannot exhaust memory in a process that never restarts. |
store |
true |
Whether a flushed batch is written to udjat_entries. false only skips the insert — entries are still collected into the batch while it runs, and max_entries_per_batch still applies. Separate from enabled because collection and storage are different costs: a consumer that wants to read the batch in-process without writing rows sets this to false instead. It must read the batch from inside the body passed to UdjatRecorder.batch() — flush’s finally clears batch.entries unconditionally once the batch closes, store: false or not, so by the time batch() returns there is nothing left in it to read. |
prune_hours |
24 |
The window udjat:prune uses when --hours is not given. |
redact_headers |
authorization, cookie, set-cookie, x-api-key, x-csrf-token |
Header names masked in a recorded request, case-insensitively. |
redact_keys |
password, password_confirmation, current_password, token, access_token, refresh_token, secret, api_key, credit_card, cvv, _token |
Body/payload keys masked wherever they appear, at any nesting depth. |
ignore_paths |
none | Path prefixes UdjatRequestWatcher never records. An ignored request records nothing at all — not merely no request entry: the check runs before the batch that every other watcher writes into is even opened, so a query or a log line issued while serving an ignored path is not recorded either. Matching is by path segment: /admin matches /admin and /admin/udjat-entries, but not /administration. A trailing slash on a configured prefix (/admin/) is tolerated. A bare / matches every path, since every path already starts with /. A prefix must start with / or it will never match — real request paths always begin with /, so 'admin' (missing the leading slash) is a silent no-op; it is not rewritten to /admin for you. An empty or whitespace-only entry is dropped rather than kept, since unlike a bare / it is never a deliberate choice and would otherwise match every path and ignore everything silently. Empty by default — Udjat cannot know a host’s own paths (a panel’s URL is set in Dart via Panel(path: ...), not config), so hardcoding one here would couple Udjat to another package. Two entries are worth adding on most applications: your Vizier panel’s own path (/admin by default), so the panel does not fill udjat_entries with rows about someone looking at udjat_entries, and /_debugbar if Mekhat or another debug-bar package is installed, since its clients poll it multiple times a second. |
The request or job watcher has to be on for anything else to
record. Every other watcher’s record call only lands somewhere if a
batch is already open, and the request middleware and the job wrapper are
the only two things that open one. watchers: {'query': true} on its own
records nothing at all — not because the query watcher is broken, but
because there is never a batch for it to write into.
enabled is off in every environment unless you say otherwise. An
observability tool that turns itself on writes a row per query in production
on the day it is installed, which is how these tools earn their reputation
for being the thing that filled the disk.
Every value falls back to a working default when it is missing or the wrong type. A malformed config leaves Udjat quiet; it never stops the application booting.
Ignoring a path from code
ignore_paths is a config file’s view of the world. A package that owns a
path config cannot see — a panel mounted with Panel(path: ...), a
polled debug-bar route — registers it directly instead, from its own
provider’s boot():
class MyPanelServiceProvider extends ServiceProvider { @override void boot() { // ...mount the panel... this.app.make<UdjatRecorder>().ignore('/my-panel'); }}UdjatRecorder.ignore(prefix) adds to the same set ignore_paths seeds;
UdjatRequestWatcher consults UdjatRecorder.ignores(path) on every
request rather than a list captured when the watcher was built, so it does
not matter whether the calling provider boots before or after
UdjatServiceProvider — every application’s provider list decides that
order, and Maat runs every provider’s register() before any provider’s
boot(), so UdjatRecorder is always there to make() regardless.
Udjat’s own provider does not call ignore() for a Vizier panel
automatically. It could only do that reliably if VizierManager already
had a panel registered by the time UdjatServiceProvider.boot() runs, but
Vizier.panel(...) is itself called from an application provider’s
boot() — the same phase, not an earlier one — so whether it has run yet
depends entirely on provider order, which is an application’s call
(withProviders/withExtensions), not something Udjat can assume. Guessing
at that order would make the fix work in today’s example apps and silently
stop working the day someone reorders their provider list. A package that
serves a path like this should call ignore() itself, the way this section
shows, rather than leaving it to every consuming application’s config.
Correlation
The unit Udjat correlates around is a batch: everything one request or
one job caused. The request watcher (or the job watcher, for queue work)
opens a batch before anything else runs, every other watcher’s record call
finds that batch through Zone.current — nothing is threaded through the
call stack — and the batch is written as one insert when the request or job
ends.
Work that belongs to neither a request nor a job is deliberately not
recorded. A batch is the thing that makes an entry meaningful — it is what
lets the panel show “everything /orders caused” as one page — and there is
no sensible batch to attach orphaned work to. A log line written during
application boot, before any request has started, is a case Udjat is
intentionally silent about; it belongs in the log, which is where it stays.
Redaction
Redaction happens at record time, never at render time: the table outlives the request and is readable by anyone who can open the panel, so a secret that reaches a row has already leaked — masking it only in the view that renders the row does nothing for the row itself.
It also happens over the whole entry in one pass, not field by field. Masking is applied once to the full map a watcher built, so a field added to that map later is masked by default; letting one field through unredacted would take a deliberate opt-out, remembered forever, instead of the current behavior where forgetting to add a new field to some allowlist costs nothing.
Walking that map, only String, num, bool, and null are kept as-is —
anything else the walk reaches as a leaf (a domain object, a DateTime, a
Duration) is stringified via '$value' rather than returned untouched
(redaction.dart, Redactor._value’s default arm). This is not
formatting: content is stored through jsonEncode (entry.dart,
UdjatEntry.toMap), and jsonEncode calls toJson() on anything that
defines one — which would silently expand an opaque leaf into structure
after redaction had already walked past it, smuggling whatever fields
that object’s toJson() returns (a password-hash column on a model, say)
into the stored row unmasked. So a User model living inside an entry’s
content is stored as the text Instance of 'User', not as its fields —
on purpose: a redactor’s job is to fail toward less information, never
more, and losing detail is recoverable while leaking a secret is not. A
watcher that wants a domain object’s fields captured has to turn it into a
Map itself first, so this redactor can walk and mask it field by field;
nothing recovers that structure automatically, and restoring a toJson()
check here would reopen exactly the bypass this closed.
What Udjat cannot redact: four channels with no key to mask
Redaction works by matching a key: redact_keys masks a body field
named password; redact_headers masks a header named authorization.
Four values Udjat records carry no key at all, and are stored verbatim,
in full, with no key-based masking possible. This is deliberate, not an
oversight, and none of it softens:
- Query bindings (the
querywatcher,query_watcher.dart) — a prepared statement’s parameters are positional, matched to?placeholders by order, not name, at the point Seshat reports them. There is no key to check againstredact_keys. Recovering one would mean parsing the SQL well enough to map each?back to a column — a heuristic on arbitrary, driver-specific SQL text. - The request path (the
requestwatcher,request_watcher.dart, thepathfield) — a path segment is not a key/value pair.GET /reset/<token>or/invite/<code>puts the secret directly in the URL, and there is nothing shaped like a key to mask it by. - An event, flattened to text (the
eventwatcher,event_watcher.dart, thepayloadfield) — an event is stored as'$event', one opaque string produced bytoString(), before it ever reaches the redactor. A token or id aNotificationor domain event happens to interpolate into that string is not a map with a key to mask. - A log message, flattened to text (the
logwatcher,log_watcher.dart, themessagefield) — the same shape as the event above:'$message'is stored exactly as passed toLog.
All four fail for the same reason — key-based masking needs a key, and none
of these four values has one. Recovering one anyway (parsing SQL to map a
? back to a column, guessing that a path segment “looks like” a token) is
a heuristic against arbitrary, application-specific text: exactly the kind
of thing that looks like it works in testing and fails silently in
production against a shape nobody tried. Udjat does not attempt it, for any
of the four, on purpose.
Operationally, this means a magic-link or password-reset path is
recorded in full whenever the request watcher is on — GET /reset-password/abc123secret shows up in the panel with the secret sitting
in path, in plain sight, exactly the way a password column bound in an
UPDATE shows up in full when the query watcher is on. Do not enable
request in production against routes that carry a secret in the path
unless you have reviewed every such route, do not enable query against a
database whose parameters carry secrets unless you have reviewed every
query, and treat event/log with the same caution: whatever a domain
event’s or a log call’s toString()/interpolation produces lands in the
panel unmasked too.
What is bounded: on bindings, byte data (a Uint8List or any other
List<int>) is summarized as <N bytes> rather than stringified. On
bindings and on a recorded request’s body and headers, any value longer than
200 characters is truncated to that length with its real length kept in the
stored text (… (truncated, 483 chars)), so a reader can tell a value was
cut rather than mistake the cut string for the whole thing. Length
truncation is not redaction — a twelve-character token sails through a
200-character cap untouched.
Keeping the table small
maat udjat:prune # uses prune_hours (24 by default)maat udjat:prune --hours=48Schedule it — see Task Scheduling:
.withSchedule((schedule) { schedule.command('udjat:prune').daily();})A command, never a timer the package starts on its own. The application already has a way to run scheduled work, and a package that quietly starts a timer inside a long-lived process is one nobody can reason about.
udjat:prune --hours= is capped at 87,600 (ten years) and refuses
anything above that, along with zero, negative, or non-numeric values. Each
of those would otherwise delete the whole table: a zero window puts the
cutoff at now, and an unbounded one overflows Duration(hours: hours) into
a negative duration that puts the cutoff in the future, where every row
matches. There is also udjat:clear, which deletes every entry outright with
no window at all.
What it costs
One row per observation, buffered in memory for the life of the batch. One
INSERT per request or job, when the batch flushes — never one insert per
entry. When enabled is false, nothing is attached at all: not a single
listener is registered, so a disabled Udjat costs the application exactly
nothing, not “attached and told to keep quiet.”
Trying it in examples/todo
examples/todo ships Udjat wired into its Vizier-enabled bootstrap
(bootstrap/vizier_app.dart), with request, query, and log watchers on
behind UDJAT_ENABLED.
cd examples/todocp .env.example .envecho "UDJAT_ENABLED=true" >> .envUse vizier_maat.dart, not maat.dart, to migrate
database/migrations.dart does not list anubisMigrations — the plain,
non-Vizier app (bin/maat.dart, bin/server.dart) has no sessions and does
not need them. The Vizier console entrypoint, bin/vizier_maat.dart, prepends
anubisMigrations itself before running yours (vizier:install even prints
a reminder to add them). Migrating with bin/maat.dart migrate skips the
sessions table entirely and the panel login will fail. Always use
vizier_maat.dart for anything panel-related:
dart run bin/vizier_maat.dart migratedart run bin/vizier_maat.dart vizier:installdart run bin/vizier_maat.dart udjat:installCreate an admin user (there is no seeder in this example, so a short one-off script is the quickest way):
cat > bin/_seed_admin.dart <<'EOF'import 'package:maat/maat.dart';import 'package:todo/app/models/user.dart';import '../bootstrap/vizier_app.dart';
Future<void> main() async { final app = await createVizierTrialApp(); await User.query().create({ 'email': 'admin@todo.test', 'password': await Hash.makeAsync('secret123'), }); await app.shutdown();}EOFdart run bin/_seed_admin.dartrm bin/_seed_admin.dartRun the server, cause some entries, and open the panel:
dart run bin/vizier_server.dart # http://localhost:8000curl -s localhost:8000/ > /dev/null # a query + a request entrySign in at http://localhost:8000/admin/login with admin@todo.test /
secret123. The dashboard shows the “Udjat: last 24 hours” widget; the
Udjat Entries resource lists every recorded row, newest first; and opening
one offers a “View batch” action that redirects to its batch page, showing
everything else that request caused.
Storage
One table, udjat_entries: id (uuid), batch_id (nullable uuid), type,
content (JSON text), created_at. Two indexes: (type, created_at) for
the newest-first-within-a-type list the resource renders, and (batch_id)
for the batch page’s “everything this request caused” query.
content is JSON text rather than a column per field because its shape
depends on type, and the type list is open (see
above) — a column per field would mean a new
migration for every entry type the list ever grows to describe one stream. A
row whose JSON cannot
be parsed still renders, as raw text: an entry that cannot be read is still
evidence something happened, and a viewer that crashes on one bad row is
worse than one that shows it plainly.
Localisation
Every string the panel renders comes from udjatVizier()’s translations
map, in both en and ar. Nothing is hardcoded in the published views: a
stub with an English word baked into it renders an Arabic panel
half-translated, and the stub is the last place a reviewer looks. If you
edit a published view, keep new strings going through translations[...]
too.