Observability and audit¶
This capability note describes runtime-wired observability and audit behavior.
All diagnostic logging entry points apply the configured module/default
threshold before the console and file sink thresholds. --debug enables
debug-level console output but does not override log_modules.*=info or an
explicit module level. See log filtering.
Each client rate-limit rejection emits one rate_limit.exceeded warning,
including the cap and effective IP. Both rate_limit.exceeded and
request.rejected policy audit records are retained; the second record no
longer emits a duplicate HTTP 429 diagnostic. Filtering warnings does not
suppress either audit record or change the rate-limit response.
Unauthenticated federation version discovery is served locally without an
X-Matrix authorization warning. Remote-media connection and HTTP failure
warnings still report failed remote operations; they are not suppressed by
an info threshold.
Included now¶
Startup logs every library each process runs (0.12.45, ADR-0222), at INFO, as plain text with no
structured fields: Library <name> <version> (static) or Library <name> <version> (dynamic,
<shared object path>; built against <version>) from the server, Thumbnail worker library … for
libpng and libjpeg-turbo (or Thumbnail worker libraries not reported: <reason>, at warning when
thumbnailing is enabled) and Federation worker library … from the federation worker. The lines
carry library names, versions and loader paths only. The thumbnail worker's report reaches the
server with every byte outside printable ASCII replaced by ?, so it cannot add a log line.
Push overload emits push.delivery.dropped at warning severity. Its scope is
global or recipient for capacity drops, and allocation or admission for
resource/launch failures. in_flight, cap and deliveries are numeric counts;
capacity fields refer to the selected budget. Resource-failure fields use the
global budget. No recipient identity, gateway URL or pusher credential is logged.
Logging itself is best-effort on this already-persisted-event path.
Push retries (R14, ADR-0206) log under the rooms logger, never with a gateway URL or pushkey:
push.delivery.retry_scheduled (debug) when a failed notification is queued for another attempt,
push.delivery.retry_abandoned (debug) when a retry's pusher was removed, and at warning severity
push.delivery.retry_dropped when the retry queue (or the recipient's share of it) is full and
push.delivery.gave_up after the last retry. Each carries recipient (the user ID);
retry_abandoned carries reason, and the other three attempts (attempts made so far).
push.delivery.circuit_open (warning) carries gateway_host and recipient when a delivery or
retry is skipped because the gateway's circuit is open; the skipped notification is retried.
Email pushers (ADR-0219, ADR-0220) log under the rooms logger with recipient (the user ID),
never the email address, the relay URL or credentials: push.email.sent (debug, with unread);
push.email.failed (warning, with result and, from libcurl, detail) when an email could not be
built or sent; push.email.abandoned (warning, with attempts) after the last retry;
push.email.pusher_removed (info, with reason) when a pusher is deleted because its address is
no longer the user's 3PID; and push.email.dropped (warning, with reason) when the email queue
is full.
- Admin health summaries through
/_merovingian/admin/health. - Admin metrics summaries through
/_merovingian/admin/metrics. - Admin audit summaries through
/_merovingian/admin/audit(with?category=and?event_type=query-string filters). - Structured log redaction helpers.
contains_sensitive_marker(used bysanitize_query/sanitized_http_targetto redact query-string values) recognizes the bare keytokenas sensitive, in addition toaccess_token/refresh_token/session_token— needed becauseGET /_matrix/client/v1/register/m.login.registration_token/validitypasses the plaintext registration token as?token=. - Stable request-correlation fields (
request_id,trace_id,span_id) for local HTTP router diagnostics. - Health, metric, and hardening snapshot helpers.
- Prometheus text exposition for
GET /_merovingian/admin/metrics. - Durable audit rows for runtime startup, authentication, session, device, key API, room, media, and trust-and-safety actions. Federation audit events are not part of this durable set — see "Federation audit events are not durable" below.
- Durable admin action rows for moderation and trust-and-safety review actions.
- Account-moderation audit rows:
account.lockedandaccount.suspended(admin category) are appended when an admin locks/unlocks or suspends/unsuspends a user via/_matrix/client/v1/admin/lock/{userId}or/_matrix/client/v1/admin/suspend/{userId}, keyed by admin actor, target user, and alocked/unlocked/suspended/unsuspendedreason code. - Request-path account-state audit rows:
request.user_lockedandrequest.user_suspended(auth category) are appended when an authenticated request is rejected because the caller is locked (M_USER_LOCKED) or suspended (M_USER_SUSPENDED), keyed by actor, target path, and reason. auth.password_changed(auth category) records a password change and notes whenlogout_devices: truerevoked the user's other device tokens/sessions.auth.sso.login_token.issuedandauth.sso.login_token.redeemed(auth category) record the two ends of the SSOm.login.tokenlifecycle (Matrix v1.19 CS API §"Client login via SSO"): the former whenhomeserver::complete_sso_loginmints a short-lived login token, the latter whenPOST /loginsuccessfully consumes it viahomeserver::redeem_login_token. Neither the raw login token nor the resulting access token is logged.sso.redirect.rejected(warning severity, vialog_diagnostic) fires whenGET /login/sso/redirect[/{idpId}]rejects aredirectUrlthat is not covered byserver.sso.redirect_url_allowlist— seedocs/threat-model.md("Open redirect and login-token exfiltration via SSO redirectUrl").federation.edu.direct_to_device.store_incomplete(warning severity, vialog_diagnostic) fires whenever an inboundm.direct_to_deviceEDU targets one or more devices but fewer messages persist toto_device_messagesthan were targeted (malformed per-device field, store rejection, or backend write failure). Fields:origin,targeted,stored— never the message content or ciphertext. Previously this failure mode was silent: the sink always returnedEduDispositionStatus::acceptedregardless of what actually persisted, so a lost megolm room-key share left no trace anywhere in the logs (issue #464).key_query.remote_key_query.entries_droppedandkey_query.remote_key_query.rejected(warning severity, vialog_diagnostic, 0.12.10) fire when the client/keys/queryfederation proxy discards part or all of a remote/user/keys/queryresponse: entries for users the server was not asked about, devices whoseuser_idordevice_iddoes not match their position, or cross-signing keys with the wronguser_idorusage. Fields:originanddropped(a count) — never the key material. A peer that keeps tripping this is either broken or trying to inject E2EE identities for users it does not own.federation_proxy.authorization_unparsed(warning severity, vialog_diagnostic) fires when an inbound federation request'sAuthorizationheader cannot be parsed as X-Matrix credentials, which the proxy answers with502 malformed federation authorization. Fields:target(sanitized),header_bytes,scheme_ok. The header value itself is never logged — it carries the peer's reusable origin/key/signature credential. Previously this rejection produced no log line at all, so a peer whose PDUs were being dropped was indistinguishable from one that never called.- Server signing-key lifecycle diagnostics.
signing_key.window_refreshed(info) fires when the publishedvalid_until_tsof the active key is rolled forward;signing_key.window_refresh_failed(warning) fires when that write does not persist, meaning peers keep re-fetching against the stale window;signing_key.provider_rebuilt(info) fires when the runtime signing provider is rebuilt because it did not hold the preferred key; anddispatch.signing_identity_refreshed(info) fires fromrotate_server_signing_keywhen the federation dispatch worker is handed the rotated key.signing_key.loadedis debug, not info: it runs on every request path that needs the signing identity. Fields carryserver_name,key_id,public_key, andvalid_until_ts— never secret material (secret_sizeis a length only). federation.acl_rejected(warning severity, viaaudit_federation) fires when an inbound federation request, PDU, or room-local EDU is denied by the room'sm.room.server_acl(MSC4436). Diagnostic companionspdu.acl_rejectedandedu.acl_rejectedcarryorigin,room_id,event_id/edu_type, andreasonso operators can trace why a server was blocked without exposing key material. Like everyaudit_federationevent, this row lives only in the in-memory federation audit ring — see below.
Federation audit events are not durable¶
federation.key_resolution_overloaded records a core inbound transaction whose
sender/authorising-key lookup was locally deferred. Its actor is the authenticated
origin, its target is the request route, and its reason is a fixed admission
message, not key/signature material. It denotes retryable 503 without transaction
acceptance, not a relay trust failure; genuine failures in the same transaction
retain their separate accounting. This event uses the same bounded in-memory
ring, not durable audit storage. See
ADR-0133.
federation.pdu_deferred records an inbound PDU that ingest_pdu_event could not yet
place and kept for a retry (R03, ADR-0165);
its actor is the origin, its target the request route and its reason the ingestion
reason. It sits in the same bounded in-memory ring as the other federation.* events, as
does federation.pdu_missing_prev_state, which now means the PDU was not kept and the
sender was told so in the /send response. The transaction.accepted diagnostic carries
pdu_deferred beside pdu_missing_prev.
federation.pdu_deferral_expired records a retained PDU the retry job gave up on: 10
retries did not place it, it was kept for over 24 hours, or its stored text no longer
parsed as the event it was kept for. Unlike the other federation.* events it is written
through append_local_audit() (policy category) by the retry job, so it reaches the
persistent audit_log and GET /_merovingian/admin/audit. Its actor is the origin, its
target the event ID and its reason which limit was hit. Expiry also logs a warning with
the room ID and attempt count.
audit_federation() (src/federation/inbound_request.cpp) appends every
federation.* audit event — federation.accepted, federation.rejected,
federation.rate_limited (also emitted when a source address exhausts its bad-X-Matrix-signature budget, ADR-0081), federation.duplicate,
federation.membership_rejected, federation.pdu_rejected_auth,
federation.acl_rejected, and the rest of that family — to
FederationRuntimeState::audit_events, a bounded in-memory deque. It never
calls database::append_audit_event. This is a different code path from
append_local_audit() (src/homeserver/local_services.cpp), which is what
auth, room, media, and trust-and-safety call sites use, and which does write
through to the persistent audit_log table.
The practical consequences:
- Federation audit events are lost on process restart.
GET /_merovingian/admin/auditreads onlyruntime.database.persistent_store.audit_log(seeadmin_audit_summaryinsrc/homeserver/runtime.cpp), so it never returnsfederation.*events regardless of?category=or?event_type=.- The "audit category" column for
federation.*rows still follows the sameobservability::AuditCategoryenum (alwayspolicy—audit_federationhard-codesAuditCategory::policy), but that category applies to an in-memory row, not a database one.
Failure routing (0.5.0)¶
Failure call sites use diagnostic/audit helpers that append an audit row
at severity warning or above independently of the diagnostic threshold.
The companion request.rejected audit row for HTTP 429 is appended directly
to avoid a duplicate warning. This is not the full catalogue of audit events —
it is only the failures that are audit-routed instead of also emitting a
duplicate diagnostic warning for the same event. See "Other durably persisted
event families" below for the rest of what append_local_audit records.
| Call site | Logger | Audit category | Audit event type |
|---|---|---|---|
| Rate-limit 429 | rate_limit |
policy |
rate_limit.exceeded |
| Login rejected | auth |
auth |
login.rejected |
| Login refused by the failed-login throttle | auth |
auth |
login.throttled |
| Access-token rejected | auth |
auth |
access_token.rejected |
| Client-server request rejected | client_server |
policy |
request.rejected |
| Locked-user request rejected | client_server |
auth |
request.user_locked |
| Suspended-user request rejected | client_server |
auth |
request.user_suspended |
| Registration policy denied | auth |
policy |
registration_policy.denied |
| Federation ACL rejected | federation |
policy |
federation.acl_rejected |
rate_limit.exceeded, access_token.rejected, request.rejected,
login.rejected and login.throttled are rate-capped per kind (10 durable rows
per 60 seconds, the rest counted): see "Audit volume bounds" below.
Other durably persisted event families¶
Everything above goes through log_diagnostic_audit, which only routes
warning-or-above diagnostics to the audit log. Most durable audit rows are
appended directly via append_local_audit (src/homeserver/local_services.cpp)
from success and failure paths alike, independent of diagnostic severity.
Grepping for append_local_audit call sites finds these event-name families:
auth.*(src/auth/AGENTS.md-covered flows, viasrc/homeserver/auth_service.cpp):auth.user_registered,auth.login,auth.refresh,auth.refresh.issue,auth.refresh.rejected,auth.logout,auth.logout_all,auth.account_deactivated,auth.password_changed,auth.openid.request_token,auth.sso.login_token.issued,auth.sso.login_token.redeemed.device.*(src/homeserver/auth_service.cpp,src/homeserver/client_server.cpp):device.deleted,device.updated.account.locked/account.suspended(src/homeserver/client_server.cpp, admin category) — see "Account-moderation audit rows" above.room.*(src/homeserver/room_service.cpp, admin category):room.created,room.joined,room.joined_remote,room.left,room.left_remote,room.invited,room.third_party_invited,room.banned,room.kicked,room.unbanned,room.forgotten,room.knocked,room.event_sent.media.*(src/homeserver/media_service.cpp, moderation category):media.upload_rejected,media.upload_quarantined,media.upload_accepted,media.quarantined,media.released,media.removed,media.remote_fetch_accepted,media.remote_fetch_rejected,media.create_reserved(POST /_matrix/media/v1/create, ADR-0212),media.preview_image_storedandmedia.preview_image_quarantined(a URL preview'sog:image, ADR-0213).trust_safety.policy_rule.*(src/homeserver/client_server.cpp, policy category):trust_safety.policy_rule.upsert,trust_safety.policy_rule.delete, plus the dynamic event types trust-and-safety policy matches emit viaappend_policy_audit.runtime.started(src/homeserver/runtime.cpp, admin category).appservice.outbox.full(src/homeserver/appservice_delivery.cpp, policy category, written throughlog_diagnostic_auditat warning level): an event was not queued for an appservice because its queue is atappservice.max_queued_events. Actor and target are the appserviceid; the reason carries the cap and the number of events refused so far. Emitted for the first refusal and every thousandth after it, so a bridge stuck at its cap does not write a row per event; the exact count is the metric above.
The access_token.rejected row carries a reason that distinguishes the
failure mode: token hashing failed, session not found, user not found,
and — for an expired-but-not-revoked token (#275) — token expired. The
expired reason is emitted only when a session matching the token hash exists,
is not revoked, but is past its expires_at, so operators can tell a stale
TTL from a genuinely unknown token.
The audit row is keyed by the same actor / target / reason that
appears in the structured log line, so an operator can pivot from
stderr to audit_log without re-parsing. The log side and the
audit row are emitted from the same call site to keep them in lockstep.
Audit sink installation on worker threads (issue #420): the active
LocalDatabase* the sink writes through is a thread_local
(homeserver/local_services.cpp), installed by LocalDatabaseScope only on
the thread that constructs HomeserverRuntime (main). Every call site above
that routes through the sink-based observability::log_diagnostic_audit
overload (rather than the explicit-database overload) previously silently
no-op'd when it ran on an HTTP ThreadPool worker thread — in practice this
meant registration_policy.denied was never recorded in production, since
/register is dispatched on a pool worker. net::ThreadPool gained an
on_thread_start hook; main.cpp's serve_until_shutdown passes a callback
that installs the audit database on every worker thread in both the main
and sync HTTP pools before they start dequeuing work.
Operator recipe¶
# All rate-limit hits in the last 24h, by category
curl 'http://127.0.0.1:8008/_merovingian/admin/audit?category=policy&event_type=rate_limit.exceeded'
# All login rejections for a specific user (filter by event_type only)
curl 'http://127.0.0.1:8008/_merovingian/admin/audit?event_type=login.rejected'
# All audit rows
curl 'http://127.0.0.1:8008/_merovingian/admin/audit'
A malformed category= value returns 400 with a clear
unknown audit category: <name> error rather than silently dropping
the request. Unknown event_type= values are treated as a no-match
filter; the response is empty (still 200).
Audit volume bounds (AUTH-1, ADR-0080)¶
Five audit events are triggered by requests from clients that have proved
nothing: access_token.rejected (unknown, invalid or expired bearer token),
rate_limit.exceeded and request.rejected (413, 429 and 503 outcomes decided
before routing), login.rejected (a password login that failed) and
login.throttled (one refused by the failed-login throttle). The last two were
at first left ungated on the reasoning that the per-IP limit bounds them, which
a flood from many addresses defeats. They must not give such a client control over durable writes
or memory, so the audit-append layer bounds the volume.
- Rate-capped rejection rows.
append_local_auditpasses these five kinds throughobservability::AuditRateGate. At most 10 durable rows per event kind per 60-second window are written; further events in the window are only counted. The first row written for that kind after the window carriessuppressed=<n>appended to itsreason(for examplesession not found suppressed=4990), so an investigator sees that events were dropped and how many. The count is reported once, and only when the kind occurs again; it is not persisted separately. Every other audit event (successful logins, admin actions, room and media moderation, authenticated security events) is written on every occurrence and is never gated. The diagnostic log line the call site emits is unchanged, so a suppressed event still leaves one log line and no audit work. - Bounded in-memory windows.
LocalDatabase::audit_eventsandPersistentStore::audit_loghold the most recentmax_in_memory_audit_events(1 024) rows and drop the oldest first. Theaudit_logtable holds every row.PersistentStore::audit_log_evictedcounts rows that have left the window, andaudit_events_appended_totalreportssize + evictedso it stays monotonic. Startup hydration keeps the newest rows in insertion order on both backends: SQLite streamsORDER BY rowidthrough the window; PostgreSQL fetches the newestmax_in_memory_audit_eventsrows (ORDER BY ctid DESC LIMIT) and replays them oldest first.audit_loghas no ordering column (migration 001), so on PostgreSQL the physical positionctidstands in for insertion order; that holds because the table is append-only. GET /_merovingian/admin/auditshows the window. It lists the retained rows, and its first line readsaudit events=<retained>plusevicted=<n>once older rows have left the window. Query theaudit_logtable for older history.- The safety-report listing does not use the window.
/_matrix/client/v3/admin/safety/reportscallsdatabase::load_audit_events_by_type_prefix(store, "trust_safety.", 1000), which reads theaudit_logtable (SQLite or PostgreSQL) newest first with the prefix as a bound parameter compared literally (no LIKE), and lists the newest 1 000 rows oldest first. A store with no database backend, or a failed read (logged asaudit.query_failed), answers from the window instead. Unauthenticated traffic that fills the window therefore cannot hide an earlier abuse report. - Bounded field length.
actor,targetandreasonare cut todatabase::max_audit_field_bytes(255) bytes atdatabase::append_audit_eventandappend_local_audit, on a UTF-8 character boundary (observability::bounded_utf8). Invalid UTF-8 bytes and ASCII control characters (including NUL and newline) are replaced by U+FFFD, so a value can neither split a multi-byte character, forge a log line, nor put a NUL in a text column. Diagnostic field values passed tolog_diagnostic_auditare cut to the same length before they are logged. A loginuserordevice_idof up to the 64 KiB body cap is therefore recorded as at most 255 bytes; login semantics are unchanged. - Path-only audit targets (OPS-2, ADR-0136).
sanitized_audit_targetdiscards the entire query string before logging or storing an audit target, including unknown and percent-encoded parameter names.append_local_auditandremember_local_audituse the same bounded value for debug output and their respective storage paths; construction, INSERT statement generation and summaries enforce it too. Query-free identifiers remain unchanged. Existing database rows, backups and historical logs are not scrubbed by this change. Do not put credentials in paths, actors or reasons: this is a query-removal boundary, not detection of arbitrary secret text.
Diagnostic capture tests must preserve the actual console threshold and whether
a module override existed, rather than copying the effective default into a new
override. The logger exposes exact threshold and optional module-policy exchanges
for scoped restoration. Audit-output fixtures assert synchronous writers, configure
levels before redirecting the console buffer, and restore on normal or exceptional
scope exit. Capture fixtures also require exclusive access to std::cout and logger
configuration: the absence of asynchronous writers does not exclude concurrent
synchronous callers.
Security posture¶
Runtime metrics and audit summaries are bounded operational summaries. They report counts, event types, actors, targets, and reason codes, but do not expose plaintext passwords, bearer tokens, key payloads, media bytes, or event content.
Redaction has one boundary, and the legacy macros go through it. The LOG_*
macros passed a caller-built std::string straight to SingleLog, so a call
site could bypass redaction simply by not using StructuredLogField. Every
named SingleLog method now routes its composed line through
redact_log_message, which applies the same sensitivity rule
log_field_is_sensitive applies to structured fields, rewriting a sensitive
key=value token to key=<redacted>. The LOGF_* macros and the
string_format helper they were built on — a runtime std::string forwarded
as a printf format argument, CWE-134 — had no call site in src/ and were
deleted outright rather than deprecated.
One log record is one physical line (AUTH-9). A logged value such as a
login identifier.user or device_id is client-controlled. SingleLog
escapes the module name and message of every line it writes, console and file
alike, through escape_log_controls before appending the record's own \n:
| Input | Written as |
|---|---|
| line feed, carriage return, tab | \n, \r, \t |
| any other C0 control (U+0000–U+001F) and DEL | \xHH (lowercase hex), e.g. \x1b |
| a UTF-8 encoded C1 control (U+0080–U+009F) | \u00HH, e.g. \u009b |
| every other byte, including the rest of UTF-8 | unchanged |
A value therefore cannot forge an extra log record or send a terminal control
sequence to an operator's console. Backslash is not escaped, so a value that
literally contains the two characters \n is indistinguishable in the log from
an escaped line feed; the record boundary is still unforgeable. Redaction runs
on the escaped line and treats every whitespace character as a token boundary,
so a sensitive field at the end of a line keeps the record terminator.
Structured field values are length-capped (AUTH-9). A client can also put a
very long value into a logged field, which would inflate every line that
mentions it. Every structured field value is rendered by
render_log_field_value (src/observability/observability.cpp), the single
function diagnostic_message (the log_diagnostic* family, and so every
console and file line built from fields) and structured_log_summary use. It
redacts first (redact_log_value), then caps (cap_log_field_value), so a
truncated value can never reveal part of a secret and a redacted value (the
fixed text <redacted>) is already under the cap.
- Cap:
max_log_field_value_bytes= 2048. The cap bounds the bytes that reach the log, that is the value after control-character escaping: the cap is measured in escaped width (a\ncounts 2, a\x1bcounts 4, a\u009bcounts 6), so a value full of control characters cannot expand past it. Escaping itself still happens once, at the sink, on the whole line. - Boundary: the cut never splits a multi-byte UTF-8 character or an escape sequence; a unit that does not fit is dropped whole. A value that fits (exactly 2048 escaped bytes) is emitted unchanged; one byte more is truncated.
- Marker:
...[truncated N bytes], appended directly after the kept prefix, whereNis the number of bytes of the original value that were dropped (decimal, no separators). It is plain ASCII with nothing for the escaper to change, so a capped value is at most 2048 bytes plus about 40 bytes of marker. Example: a 1 MiB value ofais logged as 2048afollowed by...[truncated 1046528 bytes]. - Not capped: field keys, event names, and module names (developer chosen,
never client text), and the plain
LOG_*message strings that do not go throughStructuredLogField. Audit database rows are separate bound parameters, already cut to 255 bytes byappend_local_audit. - There is no JSON or other structured output sink: the console and the log
file are the only destinations of a log record, and both receive the line
composed by
SingleLog::make_log_line.
The event signer no longer logs the canonical signing payload or the signed
event JSON. It emits each one's byte count and SHA-256 digest instead, which is
still enough to compare byte-for-byte with a federation peer when triaging a
BadSignatureError.
Dropped work is counted and announced once. Two paths deliberately shed load rather than block, and both used to do it silently:
- The bounded console and file log queues discard entries once they reach
max_log_queue_size. They now keep a drop counter (console_dropped_message_count(),file_dropped_message_count()) and emit one warning per drop episode — a contiguous run of drops — not one per dropped message, which would make the flood worse. That warning is written straight to stderr, bypassing the queue it is reporting as full. local_audit_sinkno-ops while the thread-localLocalDatabaseis unset or closed. It now warns once per episode so an operator can see that durable audit persistence, not just one event, was lost.
The audit sink pointer itself is a std::atomic function pointer. It is written
during thread startup and read from arbitrary threads, and a plain pointer gave
those no happens-before edge. It stays lock-free, so the read path on every
warning-or-above diagnostic is a single atomic load.
AuditLogEvent no longer carries an append_only flag. It was initialised to
true and consulted by nothing — audit_log_insert_statement() only ever emits
INSERT — so it asserted a guarantee the code did not enforce. Real enforcement
is a database trigger rejecting UPDATE and DELETE on audit_log, which belongs
in a migration; until that exists, the honest statement is the one above.
The opt-in client_server event=sliding_sync.debug_response diagnostic is available only when the process starts with --debug; it is not an audit event and is never persisted. Its connection identifier data is limited to presence and byte length, and it contains no request bodies, tokens, event content, or encryption material.
Scrape/export contract¶
GET /_merovingian/admin/metrics is the stable scrape endpoint for operators.
It returns 200 OK with Content-Type: text/plain; version=0.0.4; charset=utf-8
and two correlation headers:
X-Merovingian-Request-Id: req-...Traceparent: 00-<trace_id>-<span_id>-01
The body uses Prometheus text exposition with stable # HELP and # TYPE
metadata ahead of each metric family. Core families currently exported are:
merovingian_server_identity{server_name="..."} 1merovingian_runtime_startedmerovingian_database_schema_versionusers_totalsessions_totalrooms_totalevents_totalaudit_events_appended_totaladmin_actions_totalmerovingian_health_status{component="...",status="ok|degraded|failed"}merovingian_federation_outbound_rejected_total: outbound federation transactions refused because the dispatch queue, or the destination's share of it, was full (ADR-0177)merovingian_federation_outbound_pending: outbound federation transactions waiting to be sentmerovingian_appservice_outbox_queued{appservice_id="..."}(gauge: events queued for the appservice),merovingian_appservice_outbox_dropped_total{appservice_id="..."}(counter: events refused because the queue was atappservice.max_queued_events, ADR-0191) andmerovingian_appservice_ephemeral_dropped_total{appservice_id="..."}(counter: ephemeral items dropped because the in-memory queue was full, ADR-0190), one series per registered appservice. A non-zero dropped counter means a bridge has been unreachable long enough to lose data and is worth alerting on.- all
media_*repository counters and gauges already tracked by the runtime
Metric names and label keys are ASCII-safe, payload-free operator fields. New
families must carry a # HELP line, a # TYPE line, and must not encode secret
material in either names or label values.
Trace correlation contract¶
Structured diagnostics for local HTTP router request handling now carry these fields in the log line itself:
request_id=req-...trace_id=<32 lowercase hex chars>span_id=<16 lowercase hex chars>
The contract is intentionally narrow: the correlation identifiers exist to join an operator's scrape or admin query to the structured request diagnostics that served it. They are not a distributed tracing system and are not yet persisted into the durable audit rows.
Deliberately not included¶
- Distributed tracing beyond the local request-correlation contract above.
- Operator dashboards.
- Retention and export policy for production audit archives.