Probe User Details: Your Own Tab in the Message Probe¶
Annotations attach diagnostics to one message, one task, or one window. Sometimes what you want to explain is bigger than any single one of those — the whole story of how this run of the handler turned this message into these tasks: which candidates it considered, the notes it left itself along the way, the running counters it built up across every hook.
Register a Pydantic model on your handler and the Message Probe grows a
second, User-defined tab that renders exactly that story, laid out the
way you declared it — no JSON dump, no ad hoc print() you have to remember
to remove.
async def arrange(self, messages, pending):
probe.set(strategy_note='picking the highest-scoring candidate per message')
...
Outside a probe, that same line does nothing — see No-op outside a probe below.
Registering the model¶
Declare a BaseModel whose fields all use probe_field(), and point your
handler’s probe_details_model at it:
from pydantic import BaseModel
from drakkar.probe import probe_field
class ScoredCandidateRow(BaseModel):
candidate_id: str
score: float
class MyProbeDetails(BaseModel):
strategy_note: str | None = probe_field(section='Arrange', view='string', default=None)
counters: dict[str, int] = probe_field(section='Arrange', view='keyvalue', default_factory=dict)
context: dict = probe_field(section='Arrange', view='dict', default_factory=dict)
candidates: list[ScoredCandidateRow] = probe_field(section='Tasks', view='table', default_factory=list)
class MyHandler(BaseDrakkarHandler[...]):
probe_details_model = MyProbeDetails
That is the whole opt-in. Leave probe_details_model at its default of
None and the User-defined tab does not appear.
Validated at startup, not at first probe
DrakkarApp(...) calls build_layout(handler.probe_details_model) while
constructing the app, so a model that breaks the rules below raises
ProbeDetailsConfigError immediately — a code-owned mistake surfaces at
boot, not three weeks later when someone finally opens the probe tab.
probe_field() reference¶
Every field of a probe-details model must be declared with probe_field() —
a plain field with no annotation is itself a startup error, since the
framework has no section or view to put it under.
def probe_field(
*,
section: str,
view: Literal['string', 'keyvalue', 'dict', 'table', 'tables', 'tree'],
label: str | None = None,
group_by: tuple[str, ...] | None = None,
default: Any = ...,
default_factory: Callable[[], Any] | None = None,
) -> Any
sectiongroups fields into a titled block in the tab. Sections appear in first-declaration order; fields within a section keep their declaration order too.viewpicks how the field renders — see the table below.labeloverrides the auto-generated heading. Left unset,countersbecomes “Counters” andstrategy_notebecomes “Strategy note” — the field name with underscores turned to spaces and the first letter capitalized.group_by(required for and exclusive toview='tree') is the ordered tuple of row-model field names the UI groups by — outermost level first, at most 4 levels.- One of
default/default_factoryis required — same as any Pydantic field with a mutable default (dict,list) — because the framework constructs the empty instance itself at the start of every probe. A field with no default is a startup error.
The six view kinds¶
view |
Field type | Renders as |
|---|---|---|
string |
a scalar (str, int, float, bool), optionally \| None |
plain text |
keyvalue |
dict[str, <scalar>] |
a flat key/value list |
dict |
dict |
a JSON tree |
table |
list[RowModel], where RowModel is a BaseModel |
a collapsible, sortable table — one column per row-model field |
tables |
dict[str, list[RowModel]] |
one sub-table per dict key, all sharing the row model’s columns — for a runtime-determined number of tables (one per input file, one per external call, …) |
tree |
list[RowModel] + group_by=(...) |
a collapsible tree grouped by the named row fields (up to 4 levels), with a sortable table of the remaining columns at each leaf |
table and tables columns come from the row model’s own fields,
prettified the same way labels are — no separate column declaration
needed. Getting the view wrong for the field’s type (a table over
list[str], a keyvalue over dict[str, list[int]]) is also a startup
error, not a silent fallback.
Grouped tables: one table per file¶
The layout is computed once at startup, so the fields of your model are
fixed — but a tables field lets the number of rendered tables follow the
data. Each distinct group you append under becomes its own sub-table,
shown under the field’s heading in first-append order:
class ImportedFileRow(BaseModel):
record_id: str
line_count: int
status: str
class FileImportDetails(BaseModel):
per_file_records: dict[str, list[ImportedFileRow]] = probe_field(
section='Files', view='tables', default_factory=dict
)
for input_file in discovered_files:
for record in parse(input_file):
probe.append(
'per_file_records',
ImportedFileRow(record_id=record.id, line_count=record.lines, status=record.status),
group=input_file.name,
)
A probe that processed three files renders three sub-tables titled by file name; a probe that processed one renders one. Every sub-table sorts independently.
On the wire the field travels as an ordered array of [group, rows] pairs
(not a JSON object keyed by group), so first-append order survives on every
backend and in every client — including group names that look like numbers,
which a JavaScript client would otherwise re-enumerate numerically. group
must be a non-empty str; anything else is rejected as a ProbeError, since
an int group and its string form would collide as JSON keys.
Tree: multi-level grouping of flat rows¶
When one grouping level is not enough — file → section → rule, say — declare
a tree field. It stays a flat list[RowModel]; the group_by tuple names
which row fields form the grouping path (outermost first, at most 4 levels):
class MatchRow(BaseModel):
file: str # level 1
section: str # level 2
rule: str # leaf column
score: float # leaf column
class MyDetails(BaseModel):
matches: list[MatchRow] = probe_field(
section='Files', view='tree', group_by=('file', 'section'), default_factory=list
)
The grouping keys travel inside each row, so filling a tree is a plain
probe.append — no group argument. The UI groups client-side in append
order and renders one collapsible level per key; each leaf shows a sortable
table of the columns that are not grouping keys. Because the tree is a
projection of a flat list, group order is append order,
same as tables.
group_by must name existing row-model fields, without duplicates — both
checked at startup, like every other layout rule.
Filling it in: probe.set / probe.append / probe.update¶
Three module-level verbs write into whatever probe is currently running, from anywhere in your handler’s call graph — hooks, helper methods, code several calls deep. None of them take a probe handle; there is nothing to thread through.
from drakkar import probe
class MyHandler(BaseDrakkarHandler[...]):
probe_details_model = MyProbeDetails
async def arrange(self, messages, pending):
tasks = []
for msg in messages:
candidates = self.score_candidates(msg)
chosen = max(candidates, key=lambda c: c.score)
probe.set(strategy_note=f'picked {chosen.candidate_id}, highest score')
probe.update('context', message_count=len(messages))
for c in candidates:
probe.append('candidates', ScoredCandidateRow(candidate_id=c.candidate_id, score=c.score))
tasks.append(self.build_task(msg, chosen))
return tasks
async def on_task_complete(self, result):
probe.update('counters', tasks_completed=1)
return None
async def on_message_complete(self, group):
probe.update('counters', messages_completed=1)
return None
| Verb | Targets | Behavior |
|---|---|---|
probe.set(**fields) |
any field | Assigns each keyword as the field’s whole new value. Validated against the model like any Pydantic assignment. |
probe.append(field, row) |
table / tree fields |
Appends one row — either an instance of the row model or a dict that validates against it. A tree row carries its grouping keys as ordinary fields. |
probe.append(field, row, group='...') |
tables fields |
Same, but into the named sub-table, creating it on first use. group is required for a tables field and rejected for a plain table. |
probe.update(field, **entries) |
keyvalue / dict fields only |
Merges keys into the existing value rather than replacing it — repeated calls accumulate. |
Calling the wrong verb for a field’s view (probe.append on a string
field, probe.update on a table), or probe.append without a group
on a tables field, does not raise into your handler — see
Error semantics below.
No-op outside a probe¶
probe.set / probe.append / probe.update are safe to call
unconditionally from production hook code — the same shape as calling
logger.debug(...) on every request. There is no if probing: guard to
remember, and nothing to strip out before shipping.
Outside an active probe, each verb checks one contextvar, finds it unset,
and returns — no model construction, no validation, no allocation beyond
the check itself. The cost in a production hot path is a single
ContextVar.get() per call.
Stage badges¶
Every successful write is stamped with the hook stage that made it — one of
deserialize, message_label, arrange, task_complete:<id>,
message_complete, window_complete, or on_error:<task_id>, matching
whichever handler-overridable hook was running when the verb was called.
This is the same stage tag the
cache-call log already uses. The
User-defined tab shows this as a small badge next to each field, so when a
field ends up with a value you didn’t expect, you can see at a glance which
hook (and, for task_complete / on_error, which task) last touched it —
without cross-referencing the timeline yourself.
Error semantics¶
Two different kinds of mistake here fail very differently on purpose:
| When | Example | What happens |
|---|---|---|
| Startup | missing probe_field() annotation, view='table' on list[str], a field with no default |
ProbeDetailsConfigError raised from DrakkarApp(...) construction — the app never starts. This is a code review problem, not a runtime one. |
| Probe time | probe.append('strategy_note', ...) (wrong view), a row that fails the row model’s own validation, the write cap exceeded |
Never raises into your handler and never crashes the probe. The failed write is recorded as a ProbeError and shown in the report’s Errors panel, exactly like an exception your handler itself raised. |
The probe-time case matters most: a bad probe.append() call is a
diagnostics bug, not a processing bug, and it must never be able to turn a
successful arrange() into a failed probe. One bad field also can’t take
down the rest — to_user_details() serializes each field independently,
so an unserializable value in one field shows as
<unserializable: TypeError> in that field alone.
Caps¶
Two limits guard against a handler that (accidentally or not) writes unbounded diagnostics during a single probe run:
| Limit | Default | Config key | What happens past it |
|---|---|---|---|
| Writes per probe | 10,000 | ui.probe_details.max_writes |
Every further set / append / update call is dropped. |
| Total serialized size per probe | 5 MB | ui.probe_details.max_total_bytes |
Same — further writes are dropped. |
The first write past either cap records one ProbeError naming the
tripped cap, its configured limit, and the config key to raise (e.g.
“max_writes cap exceeded (10000 writes) — … raise
ui.probe_details.max_writes”), so the report itself says which knob to
turn; every write after that is silently dropped rather than raising a
fresh error each time. A probe is a
single replayed message — the defaults are generous headroom for real
handler logic. Raise them in YAML (or via DK_UI__PROBE_DETAILS__MAX_WRITES
and DK_UI__PROBE_DETAILS__MAX_TOTAL_BYTES) when a probe legitimately
produces more — e.g. one tables row per record across many large inputs:
See also¶
- Annotations — the message/task/window-scoped sibling of this feature
- Observability — the Message Probe’s other panels (timeline, cache calls, sink payloads)
- Handler — the hooks you can call
probe.set/probe.append/probe.updatefrom - UI Enrichment — links, badges, formats, hints, detail panels, and custom cell renderers for your
probe_field()declarations - Declared UI Pages — the sibling opt-in surface for a handler’s own dashboard page, reusing the same
Columnenrichment