Skip to content

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.0
Terminal window
maat udjat:install

That publishes the two views the panel renders. Then wire the three pieces:

database/migrations.dart
final migrations = <Migration>[...udjatMigrations];
// bootstrap/app.dart — the recording half
Application.configure(...).withExtensions([udjatExtension]).create();
// bootstrap/vizier_app.dart — the panel half
Panel(..., 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 true in the watchers config map, if the type is one UdjatEntryType.all already names (view and model qualify; 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 any String type; watch() is what makes that type actually get kept rather than silently dropped.

Configuration

config/udjat.dart
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 query watcher, 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 against redact_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 request watcher, request_watcher.dart, the path field) — 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 event watcher, event_watcher.dart, the payload field) — an event is stored as '$event', one opaque string produced by toString(), before it ever reaches the redactor. A token or id a Notification or domain event happens to interpolate into that string is not a map with a key to mask.
  • A log message, flattened to text (the log watcher, log_watcher.dart, the message field) — the same shape as the event above: '$message' is stored exactly as passed to Log.

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

Terminal window
maat udjat:prune # uses prune_hours (24 by default)
maat udjat:prune --hours=48

Schedule 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.

Terminal window
cd examples/todo
cp .env.example .env
echo "UDJAT_ENABLED=true" >> .env

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

Terminal window
dart run bin/vizier_maat.dart migrate
dart run bin/vizier_maat.dart vizier:install
dart run bin/vizier_maat.dart udjat:install

Create an admin user (there is no seeder in this example, so a short one-off script is the quickest way):

Terminal window
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();
}
EOF
dart run bin/_seed_admin.dart
rm bin/_seed_admin.dart

Run the server, cause some entries, and open the panel:

Terminal window
dart run bin/vizier_server.dart # http://localhost:8000
curl -s localhost:8000/ > /dev/null # a query + a request entry

Sign 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.