monitoring_compat, ignored, checks the observed settings against a copy of INBUXA's data, reads the old history without an error, and purges it. The status names where each part lives, which suite covers each acceptance test, the tests not exercised, and the known limits.
37 KiB
Feature spec: monitoring history, live tracing and alerts
Status: draft, 2026-09-18. Feature 6 in SPEC.md §4.
Provenance
Written for the clean room (SPEC.md §3). Sources, and nothing else:
| Source | License | Used for |
|---|---|---|
Stalwart's registry schema: crates/registry/src/schema/*.rs and resources/schema/schema.json.gz (objects, fields, defaults, enums, permissions, lists, forms, the dashboards and layouts sections), upstream v0.16.22 |
AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | Object shapes, field meanings, defaults, what upstream flags as Enterprise, the admin views and dashboards the server has to feed |
The AGPL telemetry code left after the strip: crates/common/src/telemetry/, crates/common/src/config/telemetry.rs, crates/trc (event, key and metric definitions, the collector, span tracking, the JSON serializer), crates/http/src/api/mod.rs and diagnose.rs, crates/store key layout, crates/services/src/task_manager/, crates/common/src/auth/permissions.rs, crates/common/src/expr/, crates/common/src/manager/defaults.rs, crates/jmap/src/registry/ |
AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | What already exists and must be kept: webhooks, exporters, the metric() expression function, span ids, storage subspaces, route and token names, default permissions |
The integration suites tests/src/telemetry/*.rs |
AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL | Tested behavior of alerts, metrics history, trace history and webhooks |
The strip report, docs/fork/strip-reports/v0.16.22.json |
This repository | Which files and snippets were removed, so which behavior is missing |
Stalwart documentation (website repo): docs/telemetry/{index,alerts,history,live,management,webhooks}.md, docs/0.15/telemetry/{alerts,live,history}.md, docs/ref/object/{trace,metric,log,alert,metrics-store}.md, blog post "Announcing Dashboards" (0.9.3) |
Unlicensed public documentation: facts used, prose not copied | Retention defaults, alert semantics, the live endpoints' parameters, which parts are Enterprise |
ihasmail's admin dashboard (web/src/lib/admin/adminDashboard.ts, adminAccess.ts) |
AGPL-3.0, ours | x:Metric query behavior as observed against INBUXA's live Enterprise server when ihasmail was built |
| RFC 8620, RFC 5322, RFC 3834 | IETF | JMAP semantics; the alert message format |
No Enterprise-only file or snippet was used. The drafting session never saw Enterprise code. It writes specs only. Gaps are marked Decision or listed under "Open questions / to observe", never filled from memory of upstream code.
What it is
Three things an operator uses to see what the server is doing:
- History. The server keeps a record of every message delivery (a trace: the inbound SMTP session or the outbound delivery attempt, with its events) and a periodic sample of its metrics. Both are kept for a set period, searchable, and drawn by the dashboards.
- Live telemetry. An administrator watches events and metrics as they happen, filtered, in INBUXA Admin.
- Alerts. Rules over metrics that send an email, raise an event (which a webhook can forward), or both, when a threshold is crossed.
Upstream ships these only in its Enterprise Edition. inbuxa-server ships them
to everybody. Webhooks, the log tracers, OpenTelemetry and Prometheus export
and the x:Log view are already AGPL and aren't part of this rebuild, except
where noted.
Data model
Registry objects are unchanged from upstream, so INBUXA's existing settings
open as they are (SPEC.md §7). The upstream schema flags as Enterprise:
objects x:Alert, x:MetricsStore, x:Trace, x:TracingStore; fields
x:DataRetention.holdMetricsFor, holdTracesFor, metricsCollectionInterval,
x:Search.indexTelemetry, indexTracingFields. In inbuxa-server they're
ordinary.
Settings
| Where | Field | Default | Meaning |
|---|---|---|---|
x:TracingStore (singleton) |
@type |
schema Disabled; first boot inserts Default |
Disabled: no trace history. Default: the data store. FoundationDb, PostgreSql, MySql: a store of its own |
x:MetricsStore (singleton) |
@type |
as above | The same, for metric history |
x:DataRetention |
holdTracesFor |
30 days | Duration, nullable. How long a trace is kept |
holdMetricsFor |
90 days | Duration, nullable. How long a metric sample is kept | |
metricsCollectionInterval |
hourly, minute 0 | x:Cron: Hourly, Daily or Weekly. When metric history is sampled |
|
x:Search |
indexTelemetry |
true | Whether traces are added to the search index |
indexTracingFields |
eventType, queueId, keywords |
Which trace fields are indexed | |
x:Metrics |
metrics, metricsPolicy |
all (exclude, empty list) |
Which metrics are collected. Already AGPL, shared with the exporters |
Clean-up of expired history runs on the existing dataCleanupSchedule.
The alert, x:Alert
| Field | Meaning |
|---|---|
enable |
Boolean, default true |
condition |
x:Expression (match list and else). The alert fires when it evaluates true |
eventAlert |
Disabled, or Enabled with eventMessage (text, nullable) |
emailAlert |
Disabled, or Enabled with fromName (nullable), fromAddress, to (a set of one or more addresses), subject, body |
The trace, x:Trace (read-only)
events: a list of x:TraceEvent, each with event (an EventType),
timestamp, and keyValues: a list of key (a Key) and value (a
TraceValue: String, UnsignedInt, Integer, Boolean, Float,
UTCDateTime, Duration, IpAddr, List, Event, Null). Server-set:
timestamp, from, to (a string), size. Views: x:Trace/InboundDelivery
and x:Trace/OutboundDelivery.
The metric sample, x:Metric (read-only)
Three variants by @type: Counter and Gauge (metric, count,
timestamp) and Histogram (metric, count, sum, timestamp).
metric is a MetricType (369 values at v0.16.22).
The index task, x:TaskIndexTrace
traceId, status, due. Task type IndexTrace, permission
taskIndexTrace. Store maintenance type reindexTelemetry rebuilds the
trace index. Both already exist in AGPL code.
Permissions
sysAlert{Get,Query,Create,Update,Destroy}, sysTrace{Get,Query,Create,Update,Destroy},
sysMetric{Get,Query,Create,Update,Destroy}, sysTracingStore{Get,Update},
sysMetricsStore{Get,Update}, sysDataRetention{Get,Update}, liveTracing,
liveMetrics, taskIndexTrace. The AGPL default-permission table gives all
of these to the superuser set only: not to tenant administrators and not to
users (crates/common/src/auth/permissions.rs).
Storage
The AGPL store layout is kept: traces in subspace o
(SUBSPACE_TELEMETRY_SPAN), metric samples in subspace x
(SUBSPACE_TELEMETRY_METRIC), each keyed by a big-endian u64 id. A trace's
id is its span id, which the server already assigns from its snowflake
generator (milliseconds since an epoch in the high bits), so key order is time
order. The trace search index is SearchIndex::Tracing, with fields
EventType, QueueId and Keywords.
Existing data at cutover, Decision (revised 2026-09-19). Traces and
samples are stored under the AGPL telemetry key classes the surviving code
reads (TelemetryClass::Span and TelemetryClass::Metric, used by
reindex_telemetry and the index task), each value encoded as the matching
registry object (x:Trace, x:Metric) with the registry's own AGPL encoding,
so no new format is invented. INBUXA's existing history sits under the same
keys but in the encoding the strip removed (trc/src/serializers/binary.rs),
so it can't be read. Records that can't be decoded are skipped, never an
error, and are removed by the normal age purge (MON-17), so INBUXA's old
history ages out within 30 and 90 days. At cutover, run reindexTelemetry
once so the search index holds only readable traces. Settings, alerts and every other registry object open
unchanged.
Required behavior
Switches and defaults
- MON-1. History is on when its store isn't
Disabled. The fork's first boot insertsTracingStore::DefaultandMetricsStore::Defaultwhen none exists (manager/defaults.rs, already in place), so a new install records trace and metric history in its data store from the start, kept 30 and 90 days. An upgraded install keeps whatever it has. - MON-2. A store that can't be opened (a separate PostgreSQL that's down, or a backend not compiled in) turns that history off with a build warning, as the AGPL store builder already does. Startup continues and mail flows.
- MON-3. A change to a store setting takes effect on the next settings
reload, with no restart, like every other setting. Decision, in line
with undelete UD-6a: a change to the
DataRetentionfields in this spec or to anx:Alerttakes effect without a reload, since an operator who lowers retention or disables a noisy alert expects it at once.
Metric history
- MON-4. At each
metricsCollectionIntervaltick, every node writes one sample per metric selected byx:Metrics.metricsandmetricsPolicy:- Counter:
countis the increase since the node's previous sample. Counters in memory are totals since the process started, so the node keeps the last totals it wrote. The first sample after a start counts from the start. A counter that didn't move writes nothing. - Gauge:
countis the reading at the tick. - Histogram:
countandsumare the totals since the process started, cumulative. Decision (2026-09-19): INBUXA Admin computes the per-interval change between consecutive samples itself (features/dashboard/helpers.ts,deltaHistograms), so increases would be differenced twice. A histogram that hasn't changed since the node's previous sample writes nothing. All samples of one tick share itstimestamp. That's the shape ihasmail already reads from INBUXA: a counter holds what happened in the interval, a gauge the reading at its end.
- Counter:
- MON-5. Gauges are always written, even when unchanged, so a window with no samples at all means history is off, not a quiet period. ihasmail relies on this.
- MON-6. There's one edition, so every gauge and histogram the collector
has is collected, stored and exported:
server.memory,queue.count,user.count,domain.count, the active-connection gauges, and all eleven histograms, including ingest, index, store read and write, and DNS lookup times. Theis_enterprisearguments intrcandcommon::telemetrygo. Prometheus and OpenTelemetry exports gain the same metrics. - MON-7.
queue.countis set from the queue itself on the existing five-minute metrics calculation, not only moved by queue events, so it's right after a restart. Decision: the in-memory gauge starts at zero on restart, and nothing in the AGPL code corrects it. - MON-8. In a cluster, samples carry no node field (the schema has none).
Each node writes its own values under the same timestamp, and dashboards
aggregate them with the
sumoravgtheir cards name. A one-node install is unaffected. - MON-9. After writing a tick's samples the node emits
telemetry.metrics-stored.
Trace history
- MON-10. A trace is stored for each inbound SMTP session (span opened
by
smtp.connection-start) and each delivery attempt (span opened bydelivery.attempt-start, which includes local delivery). Other spans (IMAP, POP3, HTTP, ManageSieve) aren't stored. The upstream suite confirms this: one LMTP delivery, made while the admin was busy over HTTP, left exactly two traces. - MON-11. Decision: an inbound session in which no
MAIL FROMwas accepted or refused (a probe, a scanner, a banned address dropped at connect) isn't stored. These are most of a public server's connections and none is a message delivery. The live view and the log still show them. - MON-12. A trace holds the span's events, from the opening event to the
closing one, that are at
infolevel or above afterx:EventTracingLeveloverrides. Decision on the level. It never holds raw I/O events (*.raw-input,*.raw-output, milter read and write). Those carry message content and authentication exchanges. - MON-13. The trace is written once, when the span closes. A span still
open after one day is dropped, matching the collector's own
SPAN_MAX_HOLD. - MON-14. Server-set fields:
timestampis the opening event's time;fromis the firstfromvalue in the trace;tois every distincttovalue, comma-separated;sizeis the message size from the trace's events, or 0. Decision on the derivation. Compare with upstream (to observe, 3). - MON-15. Bounds per trace. Decision: at most 1,000 events, then one final marker event noting how many were cut; string values over 4 KiB are truncated. A trace is diagnostic, not an archive.
Search
- MON-16. With
indexTelemetryon, storing a trace schedules anIndexTracetask. The task builds one document forSearchIndex::Tracingwith the fields named inindexTracingFields:eventType: every event type in the trace;queueId: everyqueueIdvalue;keywords: every address infromandto, each address's domain, everydomain,hostname,remoteIp,messageIdandaccountNamevalue. So searchingexample.orgfinds every trace to or from that domain, as the upstream suite expects. WithindexTelemetryoff nothing is indexed, and thetextandqueueIdfilters are refused (see "Interfaces").
Retention and growth
- MON-17. On each
dataCleanupSchedulerun, traces older thanholdTracesForand samples older thanholdMetricsForare deleted, with their search-index documents. Keys are time-ordered, so this is a range delete. A record past its deadline is never returned, even before clean-up has run. - MON-18. A null
holdTracesFororholdMetricsFormeans no age limit. Decision: that's what "unset" means for the other retention durations except the archive ones. INBUXA Admin and ihasmail show a warning next to a null value. Confirm upstream's meaning (to observe, 7). - MON-19. Growth is bounded by settings, not by a byte cap:
- Metrics: the smallest interval is hourly, so at most 24 ticks a day, each at most one sample per selected metric per node. At the defaults that's at most 2,160 ticks over 90 days, and in practice a few hundred samples a tick.
- Traces: one per delivered or attempted message, at most 1,000 events each (MON-15), kept 30 days. Connections that send nothing aren't stored (MON-11). The storage dashboard reports the size of both subspaces, so an operator sees what history costs.
Live telemetry
- MON-20. Live tracing.
GET /api/live/tracingreturns atext/event-stream. Each frame isevent: traceanddata:a JSON array of events in thex:TraceEventshape:event,timestamp, andkeyValues, a list ofkeyand a typedvalue(@typeas inTraceValue). While idle it sendsevent: pingwithdata: {"interval": 30000}every 30 seconds, as upstream does (observed 5). Decision (2026-09-19), replacing the webhook format first drafted here: this is what INBUXA Admin reads (features/tracing/components/LiveTracingPage.tsx). Query parameters:filtermatches a value in any key; anyKeyname (for exampleremoteIp,domain,queueId) matches that key only; several combine with AND. Decision: keys are given in their camel-caseKeynames; the hyphenated names in upstream's docs (remote-ip) are accepted too. - MON-21. The live tracing stream never carries raw I/O events
(MON-12). Decision: raw protocol lines include credentials, and anyone
who needs them can set a file tracer at
tracelevel on the host. - MON-22. Live metrics.
GET /api/live/metricsreturns atext/event-streamof the current values of the metrics listed inmetrics(comma-separated names; all selected metrics when absent), everyintervalseconds (default 30, minimum 1). Each frame isevent: metrics, its data a JSON array of{"@type": "Counter" | "Gauge", "metric", "count"}and{"@type": "Histogram", "metric", "count", "sum"}, holding totals since the process started. Decision (2026-09-19): this is what upstream sent on INBUXA (observed 5) and what INBUXA Admin reads (features/dashboard/stores/liveMetricsStore.ts). - MON-23. Tokens. Browsers can't put headers on an event stream, so
as with the delivery tester,
GET /api/token/tracingand/api/token/metricsreturn a token, valid 60 seconds, bound to the account and to grant typelive_tracingorlive_metrics(both already defined). The token is the response body as plain text, as upstream sends it and INBUXA Admin reads it (observed 5). The stream accepts it as?token=, or a normalAuthorizationheader. Issuing the token needsliveTracingorliveMetrics, and on this fork a token with theinbuxa:adminscope (contract.md C-18). Decision (2026-09-19): the token can be used again within its 60 seconds, not once only. INBUXA Admin reconnects a dropped stream up to five times with the same token URL (LiveTracingPage.tsx), in line with the choice to match INBUXA Admin for these streams. Theinbuxa:adminscope check applies once the auth contract's scopes are built; until then the permission is the gate. - MON-24. A live subscriber is lossy: a slow client loses events, never slows the server. At most 8 live streams run at once per node, and each ends after 30 minutes, when the client reconnects with a fresh token. Decision on both numbers. The time limit means a revoked grant (contract.md C-12) or a removed permission stops a stream within 30 minutes.
Alerts
- MON-25. Evaluation. Each enabled alert's condition is evaluated
every 60 seconds (Decision) on every node that holds the
metricsCalculatetask role, against that node's current values. A one-node install always holds it. The condition language is the server's expression language, and a metric is read two ways, both accepted:metric('queue.count'), which the AGPL expression code already supports;- the name with dots and hyphens as underscores (
queue_count), as upstream's docs describe. Counter values in a condition are totals since the process started, asmetric()already reads them. Gauges are the current reading, histograms their average.
- MON-26. Firing. An alert fires when its condition goes from false to true, including the first evaluation after start. While it stays true it doesn't fire again. Once false, it can fire again. Decision: upstream's docs say email goes out "each time the condition becomes true". State is in memory, so a restart while the condition holds fires once more. A condition that fails to evaluate (a metric name that doesn't exist, a type error) is rejected when the alert is saved, and never fires.
- MON-27. Placeholders. In
eventMessage,subjectandbody,%{metric.name}%(the dotted name) is replaced by the value used in that evaluation. Whole numbers print without decimals ("3", not "3.0"); others with at most two. An unknown name is left as written. - MON-28. Event notification. With
eventAlertenabled, firing emitstelemetry.alert-event(levelwarn), withdetailsset to the rendered message and the alert's id. Webhooks subscribed to that event forward it. - MON-29. Email notification. With
emailAlertenabled, firing queues one message to every address into, through the normal outbound queue, so it's retried, DKIM-signed for a local sender domain, and visible in the queue like any other. Headers:From: "fromName" <fromAddress>(bare address whenfromNameis null),To,Subject,Date,Message-ID, andAuto-Submitted: auto-generated(RFC 3834). Body:text/plain, UTF-8. The server emitstelemetry.alert-messageonce it's queued. - MON-30. An alert email is sent even when the queue itself is the problem the alert reports. It's queued like any message. If queueing fails, the event notification (MON-28) still happens and the failure is logged.
Who may see what
- MON-31. Traces, metric samples, alerts, the two store settings and live
telemetry are server-level. By default only the superuser permission set
holds their permissions (see "Permissions"). A tenant administrator gets
forbiddenfor all of them, even if a role grants the permission, unless its tenant allows it (multi-tenancy MT-13). Decision: traces carry other tenants' addresses and IP addresses, and metrics describe the whole server. A tenant-scoped trace view is a possible later addition (open question 11). - MON-32.
x:Traceandx:Metriccan't be created or updated, as the AGPL registry already enforces. Decision, an addition: a trace can be destroyed withsysTraceDestroy, so an operator can honor a request to erase someone's delivery records before they age out. Samples can't be destroyed one by one. - MON-33. Traces are personal data: they hold addresses, IP addresses, host names and message ids, never message content (MON-12). Retention (MON-17) is the main control, and INBUXA Admin's retention form says what's kept. Nothing in this feature sends traces off the host. Webhooks and OpenTelemetry send only what an operator configures.
Failure behavior: telemetry never blocks mail
- MON-34. Trace and sample writes happen off the mail path, through a lossy collector subscriber with a bounded buffer. When the buffer is full, events are dropped and counted, and one error event is logged per minute at most. No SMTP, IMAP, JMAP or delivery step ever waits on history.
- MON-35. A failing tracing or metrics store (full, unreachable, slow) loses history and logs, rate-limited. It never fails a delivery, a login or startup.
- MON-36. A failed
IndexTracetask is retried by the task manager as now. The trace stays readable by id and by date meanwhile. - MON-37. A failed alert evaluation or notification is logged and retried on the next evaluation. It never stops the other alerts.
- MON-38. A failed clean-up leaves the records for the next run. Expired records are still hidden (MON-17).
Edition cleanup
- MON-39. Remove the
is_enterpriseand_is_enterpriseparameters and theirinbuxa:signposts intelemetry/mod.rsandconfig/telemetry.rs;Tracers::parseusesstorageagain. DropMetricandTracefromassert_enterprise_object. Replace the "Enterprise feature" stubs for/api/token/{tracing,metrics}and/api/live/{tracing,metrics}, and thecfg(not(feature = "enterprise"))branch inmanagement_access_token.
Interfaces
- JMAP, existing names, unchanged. Over
urn:stalwart:jmap:x:Alert/get,/query,/set, and thex:TracingStore,x:MetricsStore,x:DataRetention,x:Searchsingletons.x:Trace/getand/query. Filters:text,timestamp(comparison names such astimestampIsGreaterThan),queueId,event(the type of the trace's opening event: the list views usesmtp.connection-startanddelivery.attempt-start). Sort bytimestamp, newest first by default./setrefuses create and update, and allows destroy (MON-32).x:Metric/getand/query. Filters:metric(one name or a list) and thetimestampcomparisonstimestampIsGreaterThan,timestampIsGreaterThanOrEqual,timestampIsLessThan,timestampIsLessThanOrEqual. A baretimestampfilter isunsupportedFilter, as upstream. Sort bytimestampeither way, withposition,anchorandanchorOffsetpaging andcalculateTotal.x:Tracetext orqueueIdfilters whenindexTelemetryis off:unsupportedFilter, with a description saying trace search is off.
- HTTP.
/api/token/tracing,/api/token/metrics,/api/live/tracing,/api/live/metricsas MON-20 to MON-24. These are the route names in the AGPL code. Upstream's docs name/api/telemetry/traces/liveand/api/telemetry/metrics/live. Decision: serve those as aliases too. - Webhooks, unchanged. Already AGPL, and intact after the strip: the
webhook tracer is in neither the removed-file list nor the snippet list,
and upstream's docs don't mark webhooks as Enterprise.
POSTof{"events": [...]}, HMAC-SHA256 inX-Signature, batched bythrottle, stale events dropped afterdiscardAfter. - Dashboards. The schema's six dashboards (Overview, Network, Security,
Delivery, Performance, Storage) name, per card,
live(MON-22) orhistory(x:Metric) and an aggregate. The server serves what they name. INBUXA Admin draws them unchanged.
ihasmail changes
These go in ihasmail-inbuxa, not public ihasmail, which stays Stalwart-facing (SPEC.md §5).
- The dashboard already reads
x:Metricfor received, sent and memory. Keep it. Drop the Enterprise wording from its comments and code paths, and the "refused asforbidden" branch becomes a plain error. - Change the footer line that sends people to "Stalwart's own administration" to name INBUXA Admin, with its link.
- Warn on the dashboard when metric history is off (no samples in the window, MON-5), with a link to INBUXA Admin's Metrics Store page.
- Live tracing, trace history and alerts stay in INBUXA Admin (SPEC.md §5.4). ihasmail doesn't grow screens for them.
- contract.md C-19 has to list
x:Metric(get and query) among the object types theinbuxa:account-adminscope reaches, or the dashboard loses its message cards. - Translation work: two changed strings (the footer line and the error that replaces the refused case) and one new one (the history-off warning), each in ihasmail's nine languages.
Acceptance tests
Every test runs against inbuxa-server built with no Enterprise code.
- New install:
TracingStoreandMetricsStorereadDefault, retention 30 and 90 days, hourly collection (MON-1). - Tracing store set to an unreachable PostgreSQL: the server starts, and mail is delivered (MON-2, MON-35).
- Two collection ticks with traffic between: counters hold the increase, gauges the reading, idle counters write nothing (MON-4, MON-5).
queue.countis right after a restart with mail queued (MON-7).- Prometheus output includes
queue_countand the store and DNS histograms (MON-6). - One LMTP delivery, with HTTP traffic alongside: exactly two traces, one
smtp.connection-start, onedelivery.attempt-start(MON-10). - An SMTP connection that quits without
MAIL FROM: no trace (MON-11). - A trace holds no raw I/O event, and nothing below
info(MON-12). from,to,sizeandtimestampset as MON-14.- Text search for the sender, the recipient and their domain each finds both traces (MON-16).
indexTelemetryoff:textfilter isunsupportedFilter,timestampstill works (MON-16, Interfaces).- Purge with retention 1 second: all traces and their index entries gone. With 2 seconds, nothing gone yet (MON-17).
- Metric query with
timestampIsGreaterThan, and paging forward, backward and by anchor, returns consistent pages (Interfaces). - Live tracing with
?remoteIp=shows only that client's events, and no raw I/O (MON-20, MON-21). - Live metrics with
metrics=server.memory&interval=1yields a frame a second (MON-22). - Live token: expires after 60 seconds, works once, refused without
liveTracing(MON-23). - A ninth live stream is refused. A stream closes after 30 minutes (MON-24).
- Alert on
metric('domain.count') > 1 && metric('cluster.publisher-error') > 3with both conditions met: one email with the placeholders filled ("3 domains and 5 cluster errors"),From: "Alert Subsystem" <[email protected]>, onetelemetry.alert-event. The opposite condition fires nothing (MON-25 to MON-29). - The same alert with an underscore condition (
domain_count > 1) fires the same way (MON-25). - The alert fires once while the condition stays true, and again after it has been false (MON-26).
- An alert with an unknown metric name is refused on save (MON-26).
- A webhook subscribed to
telemetry.alert-eventreceives the alert (MON-28). - A tenant administrator with
sysTraceGetin a role, in a tenant that doesn't allow it:forbidden(MON-31). x:Trace/setdestroy by a superuser removes the trace and its index entry. Create and update are refused (MON-32).- A full tracing store buffer drops events without delaying an SMTP session (MON-34).
- (compat) A copy of INBUXA's data opens: alerts, store settings and retention read back unchanged. Old traces and samples it can't decode are skipped, and are gone after one purge past their age (Data model).
The gated integration suites
tests/src/telemetry/mod.rs gates four suites behind pending-rebuild:
| Suite | Needs rebuilding | Why it's gated |
|---|---|---|
alerts.rs |
Yes | It calls process_alerts(), which lived in the removed enterprise/alerts.rs. The rebuild provides a function of that shape (returns the messages it would send) so the suite runs unchanged |
metrics.rs |
Yes | It needs the metrics store's purge_metrics and the test-data generator insert_test_metrics, from the removed metrics/store.rs and metrics/test_data.rs (the latter is the dangling test_data module in metrics/mod.rs) |
tracing.rs |
Yes | It needs the tracing store's purge_spans, from the removed tracers/store.rs |
webhooks.rs |
No | Webhooks are AGPL and intact. The suite is gated only because its clean-up calls purge_spans through the shared harness. Dropping or guarding that one call lets it run now, before the rebuild |
Recommended: un-gate webhooks.rs straight away (a separate change, not made
here), so webhooks are tested while the rest is rebuilt.
Open questions / to observe
To check read-only against INBUXA's live Enterprise server later. None of these needs a write.
- How many traces a day INBUXA stores against its SMTP connection count: whether upstream stores connection-only sessions (MON-11).
- Which events and levels a stored trace holds, and whether raw I/O ever appears (MON-12, MON-15).
- How upstream fills
from,toandsizeon a trace with several recipients (MON-14). - Whether INBUXA's samples include
Histogramrecords, which gauges appear every tick, and whether counter samples are per-interval increases (MON-4 to MON-6). ihasmail's code says they are. - Whether
queue.countin INBUXA's history matches the real queue after a restart (MON-7). - INBUXA's current
holdTracesFor,holdMetricsFor,metricsCollectionInterval,indexTelemetryand store settings, so the cutover keeps them. - What a null
holdTracesFororholdMetricsFordoes upstream: keep forever, or store nothing (MON-18). - The
x:Trace/queryfilters upstream accepts beyondtext,timestamp,queueIdandevent, and thex:Metric/querycomparison names. - The live endpoints: the token response body, the SSE frame shape for
tracing and metrics, keep-alive comments, and which path INBUXA Admin
calls (MON-20 to MON-23). A
GETof each is read-only. - Whether INBUXA has any
x:Alertobjects, and whether their conditions usemetric()or underscore names (MON-25). Whether INBUXA's logs showtelemetry.alert-eventrepeating while a condition held, which settles the cadence and repeat behavior (MON-25, MON-26). - Whether upstream's tenant
Adminrole holds anysysTrace*,sysMetric*or live permission, and whether a tenant view of its own domains' traces is wanted (MON-31). - Size of INBUXA's
oandxsubspaces, to check MON-19's estimate.
Implementation status
Built 2026-09-19 from this spec, clean-room, under the multi-tenancy hand-off
brief's rules. Metric history is in crates/common/src/telemetry/metrics/store.rs,
trace history in crates/common/src/telemetry/tracers/store.rs, alerts in
crates/common/src/telemetry/alerts.rs, the live streams in
crates/http/src/live.rs, and x:Metric and x:Trace in
crates/jmap/src/inbuxa/telemetry.rs, at the paths the shared tests name;
upstream files carry hooks marked inbuxa:. All four suites in
tests/src/telemetry are un-gated and pass, so the table above is history:
they cover tests 10 and 12 (tracing.rs), 13 (metrics.rs) and 18
(alerts.rs). Tests 1, 3, 5 to 9, 11, 14 to 17, 19 to 21, 23 and 24 pass as
tests/src/system/monitoring.rs.
- MON-1 to MON-39: built, with the limits below.
- Test 26 (compat) is written as
monitoring_compat, ignored, and unrun until a copy of INBUXA's data is provided. It checks observed 1, reads the old history without an error, and purges it. It deletes history, so only ever run it against a copy. - Not exercised by a test: 2 (an unreachable PostgreSQL tracing store),
4 (
queue.countafter a restart; the gauge is set from the queue on each calculation), 22 (a webhook fortelemetry.alert-event; webhooks are the shared, unchanged path), 25 (a full buffer; the subscriber is lossy by construction), and test 17's 30-minute close. - Known limits, not requirements of this spec:
- A trace cut at 1000 events (MON-15) carries the number cut as a
totalvalue on its closing event, since no event type fits a separate marker. - The
inbuxa:adminscope check on live tokens (MON-23) waits for the auth contract; until then the permission and server-level checks apply. - A live token can be reused within its 60 seconds (Decision, MON-23).
- The alert edge trigger (MON-26) is held in memory, so an alert whose condition holds across a restart fires once more.
- The stream limit (MON-24) is per node.
- A trace cut at 1000 events (MON-15) carries the number cut as a
Observed
Settled on 2026-09-18 against INBUXA's live Enterprise server (Stalwart 0.16.22), read-only, as a server-level administrator and the throwaway test account. No upstream code was read.
- Settings (open question 6).
holdTracesFor30 days,holdMetricsFor90 days,metricsCollectionIntervalhourly at minute 0,indexTelemetryon witheventType,queueIdandkeywordsindexed. Tracing and metrics stores are bothDefault. Nox:Alertexists (open question 10). - Traces (open questions 1 to 3). 395 stored, the oldest exactly one day
old, although retention is 30 days. Why is unexplained: the history may
have been cleared a day earlier. Worth checking before cutover, since
metrics go back 24 days. Each trace is one message's journey: 4 to 44
events (10 on average), mostly delivery, queue, SMTP and authentication
results. Every stored inbound SMTP session reached
MAIL FROM(82 of 82), so connection-only sessions aren't stored, which matches MON-11. Events carryevent,timestampand typedkeyValues. There is no level field and no raw I/O (MON-12).tois a string. No stored trace had more than one recipient, so MON-14's multi-recipient case wasn't seen. - Trace queries (open question 8).
timestampAfterandafterare refused withunsupportedFilter. The accepted filters weren't listed further. - Metrics (open questions 4 and 5). 10,755 samples over 597 hourly ticks
since 2026-08-25, with no gaps: 5,598
Counter, 4,025Histogramand 1,132Gauge. Histograms are stored for eight metrics: delivery attempt and total time, DNS lookup time, ingest time, and blob and data store read and write times. Gauges arequeue.countandserver.memory. Only four metrics appear in every tick, so a counter with nothing to report isn't stored for that tick. Counter values read as per-interval increases, not running totals.queue.countmoved between 20 and 30 over the last eight hours, so it tracks the queue. Whether it's right straight after a restart wasn't tested. - Live telemetry (open question 9).
GET /api/token/tracingand/api/token/metricsanswer200with the token as a bare 90-byte body and no content type.- The streams are
GET /api/live/tracingand/api/live/metrics, with the token in thetokenquery parameter. The same token in anAuthorization: Bearerheader is refused (401). - Both answer
text/event-stream. Tracing sendsevent: pingwithdata: {"interval": 30000}while idle. Metrics sendsevent: metricswith a JSON array of{metric, @type, count}holding totals since the server started. /api/telemetry/traces/liveand/api/telemetry/metrics/livedon't exist upstream (404). The aliases in "Interfaces" are the fork's own.
- Tenant administrators (open question 11). The default
Tenant Administratorrole holds 50 permissions and none for telemetry. TheSystem Administratorrole holds all of them, live ones included.
Not settled: open question 7 (null retention) needs a write, and question 12 (subspace sizes) needs access to the store itself.