Observability
Debugging and logging designed as a feature, because whatever the framework prints when something goes wrong is an agent's entire diagnostic surface.
Status: planned. There is no logger, no trace, no dev dashboard and no magik doctor. Every output shape below is intended. Reviewed 2026-08-26.
Why this is a designed subsystem
An agent debugging a Magik app cannot attach a debugger, cannot read a stack trace over a colleague's shoulder, and cannot ask what the framework "usually" does. It has exactly what the framework prints (../idea/07-ai-first.md). A 40-frame backtrace through framework internals is not a diagnostic; it is noise with a fact buried in it.
So the design rule: every failure answers three questions in its first six lines — what was expected, what was found, and what to run next.
The error contract, extended
The code, cause, location, fix: and --json field set are defined once in 03-error-codes.md and are not restated here. What this page adds is what a failure event looks like beyond the error object:
| Element | Rule |
|---|---|
| App frames first | the location is the declaration site in app code. Framework frames are collapsed by default. |
| Expanding | --trace on any command, MAGIK_TRACE=1 in the environment, or the "show framework frames" control on the dev error page. |
| Frame labelling | expanded frames are tagged with the subsystem they belong to, so the boundary between app and framework is visible rather than inferred. |
| Declaration context | the failure names the declaration that produced the code path — screen :Orders → action :refund_order → model :Order — not just the file. |
| One string everywhere | the same code and the same fix: in the terminal, the JSON, the dev page and the log line. A paraphrase is a thing an agent cannot match on. |
Structured logging
One event per unit of work, with a stable field set. A log line without the stable fields is a bug, not a style preference.
| Unit | Emitted when |
|---|---|
| request | a request completes or fails |
| action | an action finishes |
| query | a query executes (dev: always; production: over a threshold) |
| job | a job attempt ends |
| broadcast | an event is published to a channel |
| notification | a notification is delivered or fails |
The stable field set
| Field | Meaning |
|---|---|
ts |
ISO-8601 UTC |
level |
debug info warn error fatal |
event |
request action query job broadcast notification |
correlation_id |
one id for everything descending from a request or job — the join key |
tenant_id |
which tenant. Never omitted where one exists |
actor |
the resolved actor id, or anonymous |
declaration |
what produced this — screen:Orders, action:refund_order, job:SettleBatch |
duration_ms |
wall clock for this unit |
outcome |
ok error warn |
code |
the MAGIK_* code when the outcome is an error |
| Rule | Detail |
|---|---|
| Dev format | human-readable, aligned, colourised, one line per event with the slow ones marked |
| Production format | JSON, one object per line, the field set above plus event-specific keys |
--json |
available on every CLI command, so an agent parses instead of scraping |
| Levels mean something | info = a unit of work completed. warn = something degraded but served. error = a unit failed. fatal = the process cannot continue. No debug-as-info |
| Redaction | the redaction list is part of this contract, not an add-on — see 07-configuration-and-secrets.md. Anything on it never reaches a log line, an error message or a job payload |
| Cost | logging is not free; per-query logging in production is threshold-gated, and the threshold is configuration |
The request trace
The thing most frameworks cannot produce, and Magik can because every artifact descends from one declaration: the whole chain of a single request, with timings.
request GET /orders correlation=01J8… tenant=acme actor=u_31 42.6ms ok
├─ screen :Orders 41.9ms
│ ├─ state :orders → model :Order.recent 3.1ms
│ │ └─ query SELECT … FROM orders WHERE tenant_id = $1 … 50 rows 2.8ms
│ └─ render data_table of: :orders using: :OrderRow (50×) 38.2ms ← slow
└─ 0 jobs enqueued · 0 broadcasts
| Property | Detail |
|---|---|
| Chain | screen → action → model → query → job → broadcast, in the order they happened |
| Timings | per node, with the dominant node marked |
| Availability | on demand in dev; in production for a sampled fraction and for every failed request |
--json |
the same tree as nested objects, so an agent can walk it instead of parsing box-drawing characters |
| Why it works | the declaration registry knows what produced what. A framework where the same information is spread across a router file, a template and a model callback cannot assemble this |
The dev-time surface
"What did the framework actually do" must be answerable without reading framework source.
| Surface | Answers | Status |
|---|---|---|
magik routes |
every path, the declaration behind it, the verb | planned |
magik registry [--json] |
everything registered: models, screens, actions, jobs, channels, ledgers, domains, component overrides and what they shadow | planned |
magik explain screen :Orders |
the compiled HTML, the hx-* attributes, the state vars and their queries |
planned |
magik explain query Order.recent |
the SQL that compiles to, with the injected tenant predicate visible | planned |
magik jobs status |
queued, running, failed, scheduled — counts and the oldest of each | planned |
dev dashboard at /_magik |
the same information in a browser, plus the last N request traces | planned |
magik console |
all of the above in IRB, against a booted app with reloading (08-dev-loop.md) |
planned |
The injected-tenant-predicate case is the point of magik explain query: the framework writes part of the SQL, so the framework has to be able to show it.
magik registry is not magik describe, and neither may grow into the other. registry reports what this app declared; it needs a booted app and it is on this page. describe reports what the grammar allows — every construct, declaration and option with its type, default and allowed set — and it must work in an empty directory with no app, no boot and no database, which is when an agent needs it most (01-module-map.md). They answer different questions from different sources: one reads the registry, the other serializes the option tables. A registry that started answering grammar questions would need the thing being described in order to describe it.
Diagnostic commands
| Command | Role | Status |
|---|---|---|
magik check |
the static feedback loop — the guardrails without a server (../idea/03-guardrails.md) |
planned |
magik check --scale |
tenant-scoping and sharding hazards, as warnings | planned |
magik doctor |
the environment: Ruby version and engine, Postgres reachability and version, required extensions, migration state, config keys present, backends resolvable | planned |
magik errors explain <CODE> |
the catalogue as a command | planned |
magik test --report=timing |
where test time went (04-testing-strategy.md) |
planned |
| Rule | Detail |
|---|---|
--json on all of it |
with a documented, additive-only schema. An agent must be able to rely on the shape across versions |
| Exit codes | 0 clean, 1 findings at :error, 2 findings at :warning only. Scriptable without parsing text |
| No boot required where possible | check, errors explain and the boundary scan read source; doctor needs the environment and says so per probe |
N+1 and slow queries
Failures that must surface in dev and CI, not in production:
| Detection | Signal | Where |
|---|---|---|
| N+1 | the same query shape executed N times within one request, differing only in a bound parameter | dev: a warning in the trace with both sites. CI: magik check --scale reports it |
| Slow query | over a configurable threshold | logged with the SQL, the plan on request, and the declaration that issued it |
| Unscoped query | no tenant_id in the WHERE clause — the spec's magik check --scale rule |
MAGIK_SCALE_UNSCOPED_QUERY, severity warning, because a platform-wide admin report is legitimately unscoped |
| Missing index | a scan over a table above a row threshold on a column the declaration filters by | reported by --scale as a warning with the suggested migration |
All four are warnings rather than boot failures, for the reason in ../idea/03-guardrails.md: each has a legitimate counter-example, and a guardrail with legitimate counter-examples is a warning, not a refusal.
OpenTelemetry
OTel is on the spec's wrap list, and its role here is precise: the local structured logs and the request trace are the primary tool; OTel is the export path.
| Property | Detail |
|---|---|
| Mapping | the request trace becomes spans; correlation_id becomes the trace id; declaration becomes the span name; tenant_id and actor become attributes |
| Opt-in | no exporter is configured by default. Cost is opt-in, like everything else |
| Not a dependency of the design | if OTel is absent, nothing about the local diagnostics changes. It is an output, not the source |
| Honest limit | an agent debugging a failing test does not have a collector. That is exactly why the local surface has to be complete on its own |