Signal sources
The service exposes Prometheus metrics at /metrics on HTTP_ADDR
(default :8080). /healthz checks the HTTP process; /readyz
additionally checks the store and takes the drain state into account. A
successful scrape alone is therefore no proof of working mail delivery.
OpenTelemetry exports traces as soon as OTEL_EXPORTER_OTLP_ENDPOINT is
set. OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf selects HTTP; otherwise the
service uses gRPC. telemetry.service_name and OTEL_SERVICE_NAME
determine service.name (default shardpost, since 0.13.0 in the
production profile as well; up to 0.12.x imapcephpoc). The collector
redaction does not depend on the service name; service.instance.id is the
host name. The sampler is ParentBased with a TraceIDRatioBased root
sampler: OTEL_TRACES_SAMPLER_ARG=0.1 captures roughly ten percent of the
root traces. Without an argument the ratio is 1. A different
OTEL_TRACES_SAMPLER value does not change this selection, which is fixed in code.
Reading the metrics correctly
| Metric | Meaning and limitation |
|---|---|
imapceph_connections{proto} |
Active IMAP, POP3 and ManageSieve (proto="managesieve") connections; not a complete connection count for all protocols. |
imapceph_command_seconds |
Histogram of the instrumented IMAP, POP3 and ManageSieve commands. _count is suitable for the command rate; _bucket for quantiles. |
imapceph_commands_total |
Protocol-specific events. Not a universal request counter. Evaluate IMAP ok/no/bad separately from auth, connection and poll events. |
imapceph_lmtp_deliveries_total{outcome} |
Successful and failed instrumented delivery attempts. |
imapceph_lmtp_delivered_bytes_total |
Bytes of successful LMTP deliveries. |
imapceph_rados_op_seconds{op} |
Latencies of selected operations: header_read, header_cas, list_entries, get_entries (mailbox headers and entries), omap_read and omap_cas (the versioned omap reads and the compare-and-swap writes of the index objects), index_page and index_write (the sorted indexes and queues). Not a complete RADOS I/O profile. |
imapceph_rados_inflight_ops |
The operations of imapceph_rados_op_seconds this process has started and not finished. Against the objecter throttles (ceph.client_options: objecter_inflight_ops, objecter_inflight_op_bytes) a high value shows a pod that waits in its own client, which Ceph does not see. |
imapceph_system_placement{state} |
1 for the placement of the store's own namespaces the last check found, 0 for the others: none (index pool), native (system pool, initialized there), migrated (system pool, migrated there), unknown (the last check could not read the fence or the mark; the process goes on). Servers and the push worker check every minute; see Storage: placement. |
imapceph_rados_cas_retries_total{kind,class} |
Retries after compare-and-swap conflicts. kind is the compare-and-swap: omap (the omap compare-and-swap of the account's index objects; each lost round is followed by a random wait of 1–10 ms times the rounds lost so far), header (mailbox header), modseq_restamp (flags stamped again after a modseq collision), refs (reference count of a message blob), index (shard objects of the store-wide sorted indexes, the account directory). class is the class of the object, a fixed set: fts (posting shards and the other fts*/ objects, the layout 3 objects ftx/), ftsmap (document ids), registry (mailbox registry), mbox (mailbox header and UID shards), account, index (sorted indexes), msg (message blobs), other. No account labels. Contention on one account, e.g. parallel deliveries, shows as class="fts", "registry" or "mbox"; shardpost bench measure --contention provokes it. |
imapceph_rados_listings_total{site} |
RADOS object listings by call site. A listing visits every placement group of the pool, so its cost grows with the whole pool, not with the namespace listed. Sites: list_users (namespace listing of the account backfill, and of shardpost scrub only while the account directory has no completed backfill), scrub_orphans and scrub_unknown_index (once per account and scrub with scrub.orphan_source listing or shadow, none with intents; a batch scrub with skip_idle skips them for idle accounts), purge_objects (twice per purge with purge.listing: true, the default; none with false), sweep (twice per shardpost scrub --sweep, more when a pool is listed again), admin_kv (with admin.kv_index: write: job runner every poll and cleanup on every server, every push partition holder every scan (event=push_scan_listing), admin API; with read, the default, only listings of a prefix that is not an indexed collection, since the runner and the push workers then read the queues and hint indexes and the cleanup does not run), admin_kv_backfill (shardpost index backfill kv, once per run), learning_all (with imapsieve.learning.index write or off: learning worker every second, admin API learning list; none with read), learning_purge_owner (per purge, not with read), learning_backfill (shardpost index backfill learning, once per run), system_init (the system pool init: four namespaces of each pool, once per init that writes, in shardpost system init and the bench), system_migrate (the other system tools of shardpost system, which list the index pool, the system pool and the quarantine a few times per run; never a server), bench_inventory (shardpost bench measure, twice per run) and bench_guard (shardpost bench populate, at most once per unmarked pool, stops at the first object), both in the bench pools only. No account labels. The aim is that routine paths stop listing; a rising rate at a site shows where they still do. |
imapceph_queue_ops_total{queue,op,outcome} |
Operations on the due queues (queue jobs/claimable, jobs/expiry, learning, uploads, submission or fts_due; op move — one write that removes and sets entries — or due — one read of every shard head; outcome ok or error). No account labels. |
imapceph_queue_stale_entries_total{queue,action} |
Queue entries a reader found not matching their record: rescheduled (moved to the record's position, e.g. after a crash between a heartbeat's record write and its queue move), removed (the record ended or is gone), skipped (within the ten-minute grace, its writer may still be on its way), unparsable (a key Due cannot parse; skipped and counted at every read, logged at debug level). A steady rate of rescheduled means writers fail between the two writes. |
imapceph_kv_index_errors_total{collection} |
Hint index writes of the admin store that failed after their record was written (job, plan, idem, jmap/push/acct, servers), and index writes of the learning queue (learning — a queue move after the job's record write — and learning/owner). The record stands; shardpost index backfill kv repairs an admin store entry, and shardpost index backfill learning repairs the learning queue: a rising collection="learning/owner" means owner entries are missing, so the admin list and the purge in read mode miss jobs until that backfill ran. Readers move a queue entry that lags behind its job. Should stay 0. |
imap_imapsieve_learning_wakeups_total |
Learning worker passes a queue notify started (an enqueue or a retry; only with imapsieve.learning.index: read). The once-a-second poll is not counted. |
imap_imapsieve_learning_lock_attempts_total{result} |
Attempts to take the learning worker lease (one object every process with workers locks), by result: taken or busy. A process without the lease tries every imapsieve.learning.lock_retry_seconds (±50 %), not every second; the holder does not try again. About workers / 20 per second in a cluster is expected: the combined servers, or the runners alone with workers.run: false on the front-ends. |
imap_imapsieve_learning_job_seconds{result} |
Histogram of the duration of a learning job in the holder (0.52.0), by result: delivered, retry (exit 75 or a temporary error), dead, lost (the lease was lost after the attempt; the next holder runs the job again), canceled (the process stopped and the shutdown grace passed; the job stays), deferred (no memory for the message; tried later). |
imap_imapsieve_learning_due |
Learning jobs due at the holder's last pass (up to the page of 100). Rising for long with imap_imapsieve_learning_lag_seconds: raise imapsieve.learning.concurrency (and the programs' max_parallel), or the holder's CPU. |
imap_imapsieve_learning_lag_seconds |
Age of the oldest learning job due at the holder's last pass. The criterion for learning partitions: a p99 above ten minutes for a week at concurrency 32. |
imapceph_job_claim_wait_seconds |
Histogram of the time a job waited from the moment it became claimable to its claim (0.52.0): whether the runners' slots (jobs.max_parallel × runners) suffice. |
imapceph_job_running{kind} |
Jobs this process runs, by kind. |
imapceph_job_wakeups_total{cause} |
Looks of a job runner for claimable jobs: poll (jobs.poll_seconds) or notify (a submit or a release at shutdown woke it through the claimable queue; only with admin.kv_index: read). |
imapceph_rados_notify_seconds |
Registered, but currently without a measuring call. Do not interpret it as a confirmed notify latency. |
imapceph_scrub_findings_total{kind} |
Findings in the respective scrubber process. A server scrape is no proof of a successful external scrub run. The batch scrub runs in the servers, so its findings show in their scrapes. |
imapceph_scrub_shadow_differences_total{kind} |
Differences between the listing and the intents in scrubs with scrub.orphan_source: shadow: orphan_missed, unknown_missed, marks_missed (debris the listing found that no intent names: from before 0.42.0, from an older server during a rolling update, or a write path without an intent), orphan_extra (blobs the intents take for orphans that the listing does not; must stay 0). See operations. |
imapceph_scrub_purge_unnamed_objects_total |
Objects a purge with purge.listing: true found by listing that the account's data model does not name: debris from before 0.42.0 that a purge without listing would leave for the sweep. |
imapceph_batch_accounts_total{kind,outcome} |
Accounts a batch over all accounts handled, by the kind it runs (index_check, scrub, fts_rebuild, attachment_backfill, fts_layout) and outcome: done, skipped (idle, scrub with skip_idle), gone (purged meanwhile), deferred (locked, tried again at the end) and failed. Counted per page, in the server that ran it; a page a takeover repeats counts twice. The share of skipped in a nightly scrub is the effect of skip-if-idle. No account labels. |
imapceph_fts_frozen_waits_total{result} |
Waits of an indexing or a removal for a conversion that froze the account's full-text index (layout 3), by how they ended: moved (the conversion switched generations; the operation went on in the new one), thawed (the conversion went away without a switch) and timeout (after fts.frozen_wait_seconds, or at once with a wait of 0: the delivery answered a temporary failure). A timeout outside a conversion means an unfinished conversion whose run ended: see shardpostctl fts layout show. |
imapceph_fts_stale_freeze_lifted_total |
Freezes a writer found without a conversion naming them (the run cleared its record but not the freeze) and lifted in its own write. |
imapceph_fts_layout_retries_total{reason} |
Restarts of an indexing, a removal or a search in another index generation: generation (the layout record named another generation after an anomaly), frozen (after a wait for a frozen object). A burst while conversions run is expected. |
imapceph_fts_shard_keys{kind} |
Keys of the largest object of the last conversion this server ran, by kind (postings, dictionary, headers); against fts.shard_target_keys it shows how full the objects are. |
imapceph_fts_layout_configured |
fts.layout of this server (2 or 3; the default is 3 since 0.46.0). |
imapceph_fts_layout_effective |
The layout new full-text indexes of this server get: 3 only with fts.layout 3 and the layout 3 cluster mark set, as of the last read of the mark (at startup and whenever an index is created); 0 while a server with fts.layout 3 has not read the mark yet (the startup read failed and no index was created since). A server with the default and no mark shows configured 3 and effective 2. The startup log line full-text layout of new indexes (event=fts_layout) says the same and is repeated when a server sees the mark change. |
imapceph_fts_reshard_due_total |
Accounts a writer queued for a reshard (ix.fts_due) because a layout 3 object outgrew 1.5 × its target (fts.shard_target_keys, for the dictionary fts.dict_target_keys) below its cap; each server queues a generation once per ten minutes. |
imapceph_fts_layout_runs_total{mode,result} |
Conversions and reshards of the full-text index (fts_layout jobs, the import reshard, the bench), by mode (convert: between layouts, reshard: layout 3 to layout 3, noop: nothing to do) and result (ok, error). |
imapceph_fts_shard_missing_total |
Layout 3 objects an operation or the scrub found missing (or of another generation) in the generation the layout record names. Searches and deliveries of the account fail until fts_rebuild; should stay 0. |
imapceph_fts_shards_capped_total{kind} |
Index generations whose objects of a kind (postings, dictionary, headers) outgrew 1.5 × their target while their count is at its cap (fts.shards_max; for the dictionary fts.dict_shards_max): counted by the writer that sees it, once per generation, kind and server (again after a restart). The trigger is silent at the cap, so the objects grow beyond the target from there on, and Ceph warns from 200 000 keys per object. Before 0.49.0 the counter had no label and counted planned reshards. |
imapceph_fts_convert_buffer_keys |
Keys the copy phases (P5) of the full-text conversions running on this server hold before they write them, summed over the conversions; each holds at most max(fts.convert_buffer_keys, 1024 × target objects); 0 outside a conversion. |
imapceph_fts_dict_keys_written_total{entry} |
Substring dictionary keys added by the writers and the conversions of this server, by entry: trigram (one per trigram of a term) and prefix (the one start entry of a body identifier in an index generation with the identifier filter, fts.dict_filter: ids, since 0.50.0). Removals are not counted. The share of prefix shows how much the filter saves; without the filter it stays 0. |
imapceph_fts_version_drift_total |
Full-text version markers the scrub found that do not fit their index generation (fts_version_drift, since 0.50.0): a 3 that a version before 0.50.0 left after converting a filtered generation (set to 2), a 2 that such a version wrote on an empty filtered or nosig generation (set to 3 or 4), or a 4 whose layout record lost its text preparation (reported only). |
imapceph_fts_build_runs_total{mode,result} |
Online runs of the full-text index (fts_layout, fts_rebuild without the account lock, since 0.51.0, storage), by mode (copy, reindex) and result: ok, noop, busy (another run alive, or the account locked: the worker backs off), deferred (outside the reshard window), unsupported (no layout 3 index), mark_off (the mark of online runs went after the plan: no change, no failed job), lost (another run took over), error. Alert on error: a run that did not switch (also after three holds without converging). |
imapceph_fts_build_phase_seconds{phase} |
Duration of the phases of an online run: O2 (record), O3 (freeze), O5 (snapshot and copy or reindex), O6 (catch-up and switch), O8 (clean-up). |
imapceph_fts_build_registered_total{via} |
Documents writers recorded for an online run: alloc in the id allocation (the normal way), crossing after they met the run's freeze (an indexing that began before the run; rare). |
imapceph_fts_build_catchup_rounds |
Catch-up rounds of an online run until it switched (histogram). |
imapceph_fts_build_hold_seconds |
Holds of new id allocations before an online run's switch (histogram); only accounts with a steady stream of deliveries need one. Alert when it grows: the deliveries of those accounts wait up to fts.build_hold_ms. |
imapceph_fts_soft_freeze_pass_total{op} |
Operations (index, remove) that wrote through the freeze of an online run: one per operation that met it. |
imapceph_fts_build_window_deferred_total |
Runs the fts_due worker, a batch or the run itself put off to the next reshard window. |
imapceph_fts_build_window_too_small_total |
Runs not started because their estimate is longer than the whole reshard window (window_too_small): plan the account on its own or widen fts.reshard_window. |
imapceph_quota_recount_runs_total{mode,result,reason} |
quota_recalc runs (since 0.52.0, quota) by mode (offline, online), result (ok, busy, lost, error) and, for busy, the reason (intent, move_intent: an operation of before the run did not end within quota.recount_barrier_seconds; import_lock, mailbox_lock, scrub, foreign_run: the account is held; switch: the switch did not go through within quota.recount_switch_attempts and the hold). A busy run is planned again; on a distribution account with dozens of registry writes a second it is expected, not an alarm. Alert on error. |
imapceph_quota_recount_phase_seconds{phase} |
Duration of the phases of an online recount: barrier, list, fold, switch. |
imapceph_quota_recount_captures_total{kind} |
Usage changes writers captured for online recounts: reserve, rollback, release, mailbox (a deletion), move (the gate of a MOVE between counted mailboxes), abort (a writer that took the captures beyond quota.recount_max_capture_keys or _entries and ended the run). |
imapceph_quota_recount_drift{resource} |
Absolute difference between the counted and the stored usage a recount found (histogram, bytes and messages). |
imapceph_quota_recount_hold_seconds |
Holds of the usage writers before an online recount's switch (histogram, at most quota.recount_hold_ms); only accounts with a steady stream of registry writes need one. Alert when it grows. |
imapceph_quota_recount_dead_intents_total |
Intents an online recount took for abandoned (older than quota.recount_dead_intent_seconds): a crash or an operation hanging for that long. Alert when above 0. |
imapceph_scrub_quota_skipped_total{reason} |
Scrubs that skipped an account because an online recount is under way (qr) or a mailbox_delete locks mailboxes (mblock), or stopped their repairs because their repair mark could not be renewed (sr). |
imapceph_mailbox_lock_seconds |
How long a mailbox_delete with the mailbox lock held it (lock to unregistration, histogram; since 0.52.0). |
imapceph_mailbox_lock_refusals_total{protocol,op} |
Operations refused because a mailbox_delete locks the mailbox, by protocol (imap, jmap) and operation (the IMAP command, email_set, email_create, mailbox_set). |
imapceph_lmtp_fileinto_redirected_total{reason} |
Sieve fileinto deliveries that went to INBOX because the target could not take them (locked: a mailbox_delete holds it). |
imapceph_imap_sessions_closed_total{reason} |
IMAP sessions closed with BYE because of their selected mailbox: mailbox_locked (a mailbox_delete is about to remove it), mailbox_deleted (another session deleted it). |
imapceph_jobs_mailbox_delete_runs_total{mode,result} |
mailbox_delete runs by mode (offline, online) and result (ok, busy, drift, error). |
imapceph_imap_watches{object} |
RADOS watches the IMAP watch hub of the pod holds, by object (mailbox: IDLE, account: NOTIFY). One per object however many sessions follow it, so it grows with the mailboxes and accounts in use, not with the sessions. |
imapceph_imap_watch_subscribers{object} |
Sessions following a watch of the hub; against imapceph_imap_watches it shows how much the hub shares. |
imapceph_imap_watch_rearms_total |
Watches the hub set up again after an error (the OSD dropped it, a key rotation). A burst after a rotation is expected; a steady rate is not. |
imapceph_imap_watch_refused_total |
Watch subscriptions refused because the connection was at its bound: server.imap_max_watches for IDLE, one less for NOTIFY (one stays free for IDLE). A refused NOTIFY mailbox watch makes NOTIFY SET answer NO [LIMIT] (the earlier NOTIFY state stays); a refused follow of a selected mailbox is covered by the 60 s look; IDLE is never refused. |
imapceph_auth_cache_total{op,result} |
Answers of the auth cache, by op (authenticate: logins of every protocol; lookup: LMTP recipients, master-user targets, JMAP identities, admin lookups, the push worker) and result: hit (a cached entry, positive or negative), miss (this caller asked Nauthilus), shared (this caller waited for an identical call another caller made) and bypass (an admin lookup that went past a cached "unknown"). hit / (hit + miss + shared) is the share of logins that did not reach Nauthilus on their own; miss is the load on Nauthilus. Absent when the cache is off (auth.cache.ttl: 0, static backend). |
imapceph_auth_cache_entries{op} |
Entries the cache holds, by operation; bounded by auth.cache.max_entries each. Expired entries are dropped when they are next asked for and on the flush log's poll (every 60 s). |
imapceph_auth_cache_flushes_total{scope,source} |
Flushes, by scope (account, all) and source: local (this server's admin API), remote (an entry of the flush log another server wrote, or this server's own when another came between) and resync (everything, after a lost watch, a failed first read of the flush log or a gap in it). A steady resync rate means the watch on auth-flush keeps failing. |
imapceph_auth_backend_seconds{op} |
Latency of the Nauthilus calls behind the cache (one per miss, however many callers shared it; a bearer login is up to three requests in one observation). |
imapceph_jmap_delayed_submissions_total{outcome} |
Delayed JMAP submissions (JMAP), by outcome: scheduled (a client created one), sent (the relay took it), canceled (a client or the admin API canceled it), failed (refused by the relay, its copy gone, or given up after retry_attempts), unknown (the sending was interrupted — the conversation broke while the message was transferred, or the server stopped while sending and the sending timeout passed — and it is not sent again), retried (a temporary failure before the message was transferred; tried again after a backoff) and conflict (the worker found the record changed after sending: the sending timeout was shorter than the conversation, or the account was purged; its outcome is not recorded). Counted in the server that did it. unknown and conflict should stay near 0; a rising unknown after restarts is the at-most-once rule at work. No account labels. |
imapceph_jmap_submission_queue_entries{state} |
Entries of the submission queue (all) and of those the ones due now (due), counted at most once per jmap.submission.delayed.poll_seconds by the server holding worker partition 0, up to 100 000; absent in servers that do not hold partition 0. A due that stays above 0 means no worker sends (no relay, worker: false everywhere, a worker that cannot take its lease). unowned is set by every worker each round: the entries overdue by more than a poll in partitions this server does not hold. Their holder may just be busy; persistently above 0 means probably no server holds them (servers × max_partitions below partitions). |
imapceph_jmap_push_partitions_held |
Push partitions this push pod holds (0.52.0, JMAP: Push partitions). The sum over the fresh push pods should be 64. |
imapceph_jmap_push_partitions_unowned |
Partitions no fresh push pod holds, as this pod last read the membership; every pod reports it, alert on the maximum above 0 for two minutes. Above 0 for long means the pods have no capacity left (jmap.push.max_watched_accounts or max_partitions too small for the pod count) or disagree about the membership (clocks, see the NTP prerequisite). |
imapceph_jmap_push_partition_events_total{event} |
What happened to this pod's partitions: taken, taken_orphan (taken over the share: orphaned or contested), released (given up at shutdown), shed (given up to even the load), lost (the lease's renewal failed), expired (the holder stopped the partition itself before the TTL was up, without a successful renewal, and released it), rotated (moved to the new connection after a key rotation), rotate_failed, members_stale (a cadence without a fresh membership: nothing taken or shed). lost rising for long means a lock object or an OSD in trouble. |
imapceph_jmap_push_handover_seconds |
Histogram: from taking a partition's lock to the end of the first pass of every watcher of it. p99 above 10 s: raise jmap.push.start_rate or add pods. |
imapceph_jmap_push_gap_seconds |
Histogram: from the orderly release of a partition by its previous holder (the time in its membership record) to the take by this pod. Orderly hand-overs only; p99 above 10 s means the control notifies or the membership are slow. |
imapceph_jmap_push_watched_accounts |
Accounts with push subscriptions this pod watches (counted once their state is read). Against go_memstats_heap_inuse_bytes it gives the memory per account, which sizes jmap.push.max_watched_accounts. |
imapceph_jmap_push_sent_total{outcome} |
Web Push requests by outcome: delivered, gone (the subscription is destroyed), slow (the push service asked to wait; tried again after the backoff), refused. |
imapceph_jmap_push_delay_seconds |
Histogram: from the watch event to the push it led to; shows the coalescing (one second) plus the load. |
imapceph_jmap_push_pending |
Subscriptions with a backlog (an acknowledgement behind the account state), as counted at the last save of each watched account. Not 0 while push services ask to wait; rising for long means a push service down. |
imapceph_jmap_push_scan_gets_total{cause} |
Subscription records a scan read, by cause: changed (the checksum in the index value moved; about the rate of subscription changes), nosum (an index value without checksum, written by a front-end before 0.52.0; should fall to 0 after the rollout), full (the full pass of jmap.push.full_scan_seconds). |
imapceph_jmap_push_verifyq_age_seconds |
Age of the oldest verification waiting, set by the pod that sends them (the holder of the smallest held partition). Above 60 s: nobody holds a partition, or the push services do not answer. |
imapceph_jmap_push_lock_op_seconds{op} |
Histogram of the lock operations of the partition leases: take, renew, release. A tail of seconds shows a lock object in peering; the loop never waits on it, the partition alone does. |
imapceph_jmap_limit_total{limit,outcome} |
JMAP requests that met a full limit, by limit (requests, uploads, downloads, event_sources, per_ip) and outcome: waited (got a slot within jmap.limit_wait) and rejected (answered 429, or serverUnavailable for a Blob/upload call). A rising rejected means a client sends more at once than its account may run and queue. |
In commands_total, POP3 counts only selected events; there DELE counts
deleted messages. ManageSieve counts only the authentication outcomes
(command="AUTHENTICATE", outcome ok, failed or tempfail). With IMAP, auth events can be counted in addition to the
protocol response. Using the sum of all outcomes as the denominator of an
error rate would mix different events. In the current histogram, IMAP UID
commands are recorded together under UID.
Missing series are not zero values. In particular, many labeled series only come into existence with the first event. For quantiles, the histogram buckets have to be summed across the selected pods before the quantile is computed; per-pod quantiles must not be averaged. Without observations, quantiles are not meaningful.
Traces and access
One trace is started per unit of work: an IMAP, POP3 or ManageSieve
command (imap.FETCH, pop3.RETR, managesieve.PUTSCRIPT; an unknown
command is managesieve.UNKNOWN), a delivery (lmtp.deliver,
delivery.deliver), an admin request (admin.<operation>, the
operation label carries no account name), a JMAP request and a
maintenance job (job.<kind> with job.id, and job.account when
personal attributes are on). The store reports its own work below them,
so a trace shows where the time of a command actually goes:
imap.FETCH
├─ store.get_entries rados.oid, number of UIDs
└─ store.blob_read guid, bytes
A JMAP request is one span per HTTP request — jmap.session,
jmap.api, jmap.upload, jmap.download, with http.method,
http.status_code, net.peer and, once authenticated, jmap.user — and
below jmap.api one span per method call, named after the method
(jmap.Email/query) with jmap.method, jmap.account (the account the
call works on: the user's own, another owner's or ~public) and, when
the call failed, jmap.error (the JMAP error type). Only a server-side
failure (serverFail, serverUnavailable, serverPartialFail, or an
HTTP status of 500 and above) marks a span as an error; a client's
mistake is an attribute. The store views of the request — the user's own
and every shared namespace a call opens — hang their operations below
the call that runs them:
jmap.api
├─ jmap.Email/query jmap.account
│ ├─ store.fts_search
│ └─ store.get_entries
└─ jmap.Email/get
└─ store.blob_read
The event source (/jmap/eventsource/) is not traced: it stays open for
hours, and a span that long says nothing. Neither are the health
endpoint and a call of an unknown method. No JMAP attribute carries a
credential, a filter value, a mailbox name or anything of a message.
Store spans: store.deliver, store.blob_read, store.blob_write,
store.fts_index, store.fts_search, store.fts_dict (the dictionary
keys of new or vanished terms, with their number), store.header_read,
store.header_cas, store.list_entries, store.get_entries,
store.flags, store.copy, store.move, store.expunge, and
rados.omap_cas for the central compare-and-swap loop (the one behind
most omap writes; store.header_cas is the mailbox header in
particular). The names match the labels of the RADOS metrics where both
exist. A failed operation carries the error and an error status on its
span.
The protocol layers hand the context of the running command to the store
view (model.RequestScoped), including the views of other users' shared
mailboxes. The store only ever creates child spans of a sampled
command: work with no command above it — a background notifier, a scrub
walking a whole namespace — produces no spans at all, so a single job
cannot turn into millions of traces. A store that does not implement the
interface produces no store spans either.
One command or job contributes at most 1000 store spans. A job that
walks a namespace would otherwise put millions of children into a single
trace, which the batch processor drops and the backend refuses; when the
limit is reached, the root span carries store.spans_truncated=true and
the rest of the work is not traced. The job's own progress and
checkpoints remain the way to follow a long run.
Store spans deliberately carry no mailbox names, only object ids, counts
and sizes; mailbox names are user content. Personal attributes — the
account (imap.user, pop3.user, managesieve.user, lmtp.user,
delivery.account, jmap.user, jmap.account, job.account), the
admin client (admin.principal), the IMAP mailbox (imap.mailbox), the
client address (net.peer, IMAP and JMAP) and the text of an error,
which can echo client input or mailbox names — are only added with
telemetry.user_attributes: true (env TELEMETRY_USER_ATTRIBUTES,
default off). Off, a failed span records the error's type (error.type)
instead of its text, a trace holds no personal data and the collector
filter accepted on 2026-09-15 (below) is a second line of defence, not
the only one; on, the collector configuration and the access rights have
to implement the applicable rules for this data. ManageSieve spans carry
the command name and, if switched on, the account, never arguments:
AUTHENTICATE carries credentials, PUTSCRIPT and CHECKSCRIPT the
script. Credentials belong neither in dashboards nor in export files or
operational reports.
Operational responsibility and acceptance
Enabling this in production belongs in the authoritative manifests of the
Kubernetes owner repository. Scrape annotations in this source project
alone are no proof of collection by a Prometheus server. The metrics owner
is responsible for the connection to metrics.roessner-net.de, for the data
sources and for the operator dashboard in the Grafana folder Mail.
A complete acceptance comprises ready pods, current Prometheus series for every expected target, an actual service trace in Tempo as well as the stored and rendered dashboard. A read-only IMAP command can verify the trace path without a test delivery; with sampling, several commands may be necessary. Missing live evidence explicitly stays open.
Production acceptance on 2026-09-15
- Namespace
imap, deploymentimap-server: two ready pods; both central Prometheus targetsup=1after the final rollout. - Scrape labels:
job="kubernetes-pods",namespace="imap",app_kubernetes_io_name="imap-server",pod. - OTLP:
http://192.168.0.102:4318,http/protobuf, sampling0.1. - Mail Operator in Grafana: version 2, 21 panels including section headings and interpretation; API readback, rendered rate/latency/LMTP panels and logs/Tempo links verified.
- A fresh, real trace after the final activation (13:58:38 UTC,
5e9e1e204e89112703b8ea9f284236f2,imap.STARTTLS) was verified independently in Tempo by the Kubernetes owner: none of the five user/mailbox/peer attributes named above. The preceding synthetic filter test was confirmed by both owners. - CPU/RAM series are currently not present in the central Prometheus. The dashboard shows the gap. Missing POP3/error series are not artificially set to zero. Rate queries use at least four minutes; this window was verified against existing series.
During the first filter rollout, the activation replaced the host file atomically but did not force a new collector container. The still healthy collector therefore kept exporting unfiltered traces. The service export was rolled back, the collector was deliberately recreated, and the effect of the filter was verified synthetically before the activation was explicitly approved again. Older traces that are already stored are not cleaned up retroactively by this.
Reproducible dashboard source in the metrics owner repository (since 0.13.0):
grafana/tools/build-shardpost-operator-dashboard.py and
grafana/dashboards/shardpost-operator.dashboard.json, dashboard UID
shardpost-mail-operator
(Grafana,
since 2026-09-18; the old imapcephpoc-mail-operator has been backed up and removed). The queries cover the old and the new labels
(namespace imap|shardpost, service.name imapcephpoc|shardpost).
Kubernetes owner contract: shardpost/telemetry-patch.yaml and
reports/2026-09-15-imapcephpoc-observability.md.
Structured session logs from 0.11.3
slog writes JSON to stdout; Kubernetes uses LOG_LEVEL=info.
connection_open, auth_success/auth_failed, tls_established and
connection_close make the connection lifecycle visible. session_id
ties the events together; remote is the effective PROXY client address.
Director health checks produce connection logs as well. Logs are not
filtered by the OTLP sampling rate.
Debug adds the known command names and durations, without arguments or
mail contents. For troubleshooting with a client, 0.18.0 introduced the
time-limited protocol trace per account (event=protocol_trace, see
Admin API). Details, limitations and a Loki example are in the
configuration documentation, the dated evidence in the
logging report.
Rendered from docs/observability.md in the ShardPost repository.