Telemetry and the Aspire dashboard
The host instruments itself with OpenTelemetry throughout — logs, metrics, and traces — and exports none of it unless the environment names a destination. Today exactly one environment does that out of the box: a local run under the Aspire orchestration, whose dashboard is the destination. This page records what is emitted, the one switch that decides whether it leaves the process, and why the deployments deliberately ship with that switch off.
What the process emits
Logs go through the OpenTelemetry logging provider with formatted messages, beside the console output that is always on. Scopes are left out of what it exports, which is a redaction decision rather than a preference: the only scope anything opens here is the one ASP.NET Core puts around every request, it carries the path exactly as that request arrived, and one route on this host serves a path that is itself a credential. Correlation does not depend on it — every record carries the trace and span identifiers of the request that produced it, and the span carries the path with the capability already removed. Log lines are structured with named properties, and by contract they never carry credentials, tokens, message bodies, attachment content, or raw MIME; a tool call is recorded as a name, an outcome, and a duration, never as what was searched for. What the endpoint records states that boundary precisely.
Metrics cover the request pipeline (ASP.NET Core), outbound HTTP (HttpClient), and the .NET runtime through their
instrumentation packages, and five meters that the libraries publishing them name themselves:
| Meter | What it reports | Subscribed by |
|---|---|---|
Npgsql |
Connection-pool state, command durations, and command counts against PostgreSQL | The Aspire PostgreSQL enrichment |
Microsoft.EntityFrameworkCore |
Active contexts, queries, save operations, compiled-query cache hits and misses, execution-strategy failures, and optimistic-concurrency failures | The host |
Experimental.ModelContextProtocol |
MCP session duration, and per-operation duration broken down by protocol method and — for a tool call — tool name | The host |
Polly |
Every outbound-resilience pipeline's attempts, outcomes, timeouts, and circuit-breaker state transitions | The host |
Experimental.Microsoft.Extensions.AI |
One provider call's duration and the tokens it consumed, broken down by operation, provider, model, and token type | The host |
The split in the last column is where a meter is registered, not how important it is: the Aspire enrichment that gives
the EF Core context its health check and its database tracing subscribes Npgsql as part of the same call, and the
host subscribes the four it leaves out. Nothing is subscribed twice.
Outbound resilience records which tags the Polly
events carry and which they never do; the optimistic-concurrency counter is the aggregate view of the same conflicts
that surface individually as a persistence conflict failure.
Traces cover incoming requests, outbound HTTP, database commands, MCP protocol operations, and calls to an AI
provider, correlated end to end: the trace a request arrives with is the trace its log records and its failure
diagnostics carry, and a model call appears inside the MCP request that caused it rather than beside it. The MCP spans
come from the SDK's own Experimental.ModelContextProtocol activity source and carry the protocol method, the
negotiated protocol version, the transport, the session identifier, the JSON-RPC request identifier, and the tool name
for a tool call — which is what makes a slow call attributable to a tool before anything inside the tool is
instrumented. Database commands are spanned by the Npgsql source rather than by EF Core, which reports through
DiagnosticSource and would need a bridging package to span the same commands a second time. Between those two ends
sit MailFathom's own spans, which say which use case ran; what a request-path trace contains
is where they are named.
One filter is deliberate: requests to the health-probe paths are not traced at all, because a probe arrives every few seconds for the life of the process and says the same thing every time — tracing it would fill a trace store with polling instead of work.
One attribute is deliberately rewritten. An attachment download
carries a signed capability in its path, and whoever holds it can fetch that file until it expires, so the span records
the route template /attachments/{capability} in place of the path the request arrived with. The span itself is kept,
because a download is real traffic an operator has to be able to see; what is removed is the one segment that is a
secret. Nothing else in the pipeline writes it down: no log line here mentions a download, the exported log records
carry no request scope, and the framework's own request logging is off at the shipped Microsoft.AspNetCore level of
Warning. Two settings put the path back — that log level, and Logging:Console:FormatterOptions:IncludeScopes, which
turns scopes on for the console output rather than for the exporter — and
the MCP endpoint states what each one costs.
Every tag on the metrics above is a bounded set — a protocol method, a transport kind, a negotiated version, one of the three tool names, an outcome, and on the AI instruments the operation, the provider, the requested and answered model names, the configured endpoint's address and port, and the token type — so none of them opens a time series per message or per person. The MCP SDK does tag a metric with a resource URI, but only for the protocol's resource methods, and MailFathom's server publishes tools alone: no resources and no prompts, so the tag never arises.
What a call to a model emits, and what it never does
A chat model and an embedding model are reached through one client construction, and every client it builds is wrapped
in the telemetry decorators Microsoft.Extensions.AI publishes. That is a property of the construction rather than of
any call site: an adapter added later reaches a provider the same way and is spanned without anything else being
written. The decorator sits innermost, beneath the resilience pipeline and the spend ceiling, so a span measures one
attempt against the provider — a call retried three times is three spans rather than one slow one, which is what makes
a provider's own latency separable from the retrying around it. Spans and instruments both arrive under
Experimental.Microsoft.Extensions.AI, which is the library's name for them and follows OpenTelemetry's semantic
conventions for generative AI, still marked experimental upstream.
The spans are complete; the instruments are not, under sustained load. A client is built per provider call, so each call constructs the decorator anew — and the decorator creates a meter of its own with it. Every construction therefore allocates two fresh metric streams, and the OpenTelemetry SDK caps a provider at 1000 of them; the cap is reached after roughly 250 provider calls within one export interval, and the streams are reclaimed at the next export. Measured against the pinned SDK: 400 calls in one interval produce 400 spans and 250 measured calls. An ordinary interval's worth of questions is far below that and is measured in full, and an embedding backfill running at full concurrency is the shape that exceeds it — which shows up as an undercounted call rate and an undercounted token total for that interval, never as a missing span or a wrong duration on the calls that were recorded. Reading a backfill's cost from the provider's own accounting rather than from this meter is the remedy while that holds.
A prompt and a completion are never recorded. The library will capture both — the question somebody asked of their
mailbox, the answer a model gave, the passages an embedding request carried — and it turns that capture on from the
environment variable OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT. MailFathom sets the switch explicitly where
it builds the client, and an explicit value takes precedence, so that variable has no effect on this process. There
is no configuration key for it either: a collector holding every question and answer would be a second copy of the mail,
under a retention nobody chose and an access rule the mail store never granted, and this is not a trade an operator is
offered. What remains is metadata — which operation ran, against which model and endpoint, how long it took, how many
tokens it consumed, and whether it failed.
The build every record names
Every record the process exports carries the build on its resource — a log record, a metric point, and a span alike — read from the host assembly's own build-time metadata. That is what makes a deployment serving two versions at once readable: a regression is grouped by the build it appeared in rather than inferred from the moment it started, and a collector's history stays attributable to a build long after the rollout it belonged to.
It is two attributes, because the build answers two questions:
| Attribute | What it says |
|---|---|
service.version |
The semantic version, which is the compatibility statement an operator groups deployments by |
vcs.ref.head.revision |
The commit the assemblies were built from, which is what makes a report reproducible |
service.version carries the prerelease identifier where the build has one and never the revision, which the stamp
keeps after SemVer's plus sign. So a release reports the plain three-part version and a nightly reports
<version>-nightly.<run number>-<short revision>, the identifier that image's tag carries, because the two are stamped
differently at build time rather than told apart at run time. The process holds no notion of the channel it was
published on, and needs none.
vcs.ref.head.revision is the name OpenTelemetry's attribute registry publishes for a head revision. The service.*
namespace has none for build provenance, and an attribute invented here would be one no backend recognizes. It is
abbreviated to the same seven characters the identifiers above carry, so a value can be pasted into git show as it
stands; the whole object name is on the image's org.opencontainers.image.revision label for anything that needs it. It
reads unknown where the build stamped no revision, which is what a build with no repository beside it produces — an
image built outside the publishing pipeline is the case that occurs — and is a legitimate state rather than a fault.
ADR 0004 holds the whole scheme, including which inputs each build shape supplies.
Both values are the assembly's stamp rather than configured ones, so a deployment cannot make its telemetry claim a
build the process is not running — and neither attribute written into OTEL_RESOURCE_ATTRIBUTES overrides the stamped
one. That precedence is deliberate rather than incidental: the build is a fact about the running process, and the
variable is the one thing that could make it wrong. They are the same two values the startup records report as
properties, and the version is the one the MCP surface reports to a client during initialize, from the same source, so
no two of them can disagree.
The rest of the resource is left to the OpenTelemetry SDK. service.name comes from OTEL_SERVICE_NAME, or from the
SDK's unknown_service:{processName} fallback where that is unset, and nothing names the service a second time;
host startup telemetry records why one process reporting under two identities is the
failure that arrangement avoids.
What MailFathom publishes under its own name
Everything above arrives from a library. MailFathom publishes under a name of its own, and there is exactly one of
them: MailFathom. It serves as both an activity source and a meter — the two are separate registries to
OpenTelemetry and cannot collide, so spans and instruments go under one string rather than two that could drift apart.
One name is what an operator filters a dashboard on to see everything this process owns and nothing a library emits. No subsystem has a name of its own, and none gets one until there is something a name is the right way to tell apart: which subsystem a signal came from is already carried by the span or instrument name and by its tags, and a distinction added there costs an operator nothing, while a second registration is one more thing to subscribe to before anything is collected.
There is also one instance of each registry, held for the lifetime of the process, and a subsystem starts its spans and creates its instruments on those rather than constructing its own. That is what makes a name invented for a feature impossible rather than merely discouraged: there is no second source to give a different name to. Neither is disposed, because disposing a shared source would silence every other publisher, so a type that reports through them implements no disposal on their account.
What publishes to that name is documented with the subsystem that does it.
Every change MailFathom makes to a remote mailbox opens a span named after the mutation, and is counted by
mailfathom.mailbox.mutations and timed by mailfathom.mailbox.mutation.duration, both broken down by the mutation,
the account, the folder alias, and whether it succeeded. It is deliberately
not broken down by which IMAP commands carried the change — a relocation is one operation whether the server
offered RFC 6851 MOVE or the copy-flag-expunge sequence was used instead, and a dimension telling the two apart is
exactly what would make a missing server extension look like a different operation on a dashboard. Which path ran is in
the debug log.
That counter answers what happened; two gauges beside it answer what has not happened yet, which is the question an
operator opens a dashboard with. mailfathom.mailbox.mutations.outstanding reports how many changes an account has
asked a mail server for and not seen finished, and mailfathom.mailbox.mutations.oldest_outstanding_age reports in
seconds how long the oldest of them has been waiting. Both carry the account, the mutation, and a lifecycle of
pending, converging, or dead-lettered; a completed change is not on them, because it is already the counter's
success outcome and an age is meaningless for it. Each account's values are republished by its own convergence pass and
replaced whole, so a lifecycle that empties stops being reported instead of reporting its last non-zero value forever.
A dead-lettered count that stops falling is the reading worth alerting on: those are changes nothing will attempt
again, waiting for somebody to look.
One counter beside them answers a question about the record rather than about the change.
mailfathom.mailbox.mutation.audit.refused_appends counts the audit entries a finished mutation owed and the trail
could not be given, by mutation and account, alongside a warning naming the record. It is zero on a deployment that
keeps no trail, because nothing is owed there. On one that keeps a trail it is the reading worth alerting on outright:
the change was made and the history of it is missing, which is exactly the gap an audit cannot recover afterwards. It
exists because writing an entry may never fail the mutation that produced it — a trail that could roll back somebody's
mailbox would be worse than a reported hole — so swallowing the failure is only defensible while it is counted.
Synchronization publishes the byte volume it moves, which is what storage is sized from — counting messages says
nothing about it, since one message is anywhere between a kilobyte and the configured size limit.
mailfathom.mail.content.fetched and mailfathom.mail.content.stored count the raw MIME bytes each folder run read
from its mail server and wrote to local storage, by account and folder alias, and are read as a rate: how much a mailbox
costs per interval. mailfathom.mail.content.stored_total is the level that rate is filling — how much storage the
stored content occupies, as the most recent run measured it. It carries no account or folder dimension on purpose,
because content storage is one store every account writes into and a per-account copy of the same number would invite a
dashboard to sum it.
mailfathom.mail.content.limits_reached counts the folder runs that ended against one of the two byte limits, tagged
with which: run_budget for a run that spent what it may fetch, and storage_ceiling for one that had to record
messages without their content. Both are counted rather than only logged because both are conditions that persist — a
run that stopped for its budget will stop again next interval, and a deployment at its ceiling stays there until
somebody acts — so a rising count says it has been running that way rather than that it did once.
Bounding how much mail a run brings in
states what each limit does when it is reached and how the gap a ceiling leaves is closed.
Embedding publishes the depth of its backlog, how many messages the bound turned away, and how many messages and passages it embedded and how long that took, broken down by outcome and by the classification of a provider failure. Automatic embedding names each instrument and what it answers; the depth is the one an instance falling behind shows up in first.
Three counters beside those answer what embedding is costing rather than how it is going.
mailfathom.embedding.budget.consumed is the characters sent to a provider and charged against the spend ceiling,
which summed over a period is what that period spent and summed over any other window is a question a ceiling cannot
answer; mailfathom.embedding.input.truncated and mailfathom.embedding.input.omitted are how many messages the
per-message ceiling cut short and how much text it left out. The consumed figure is a counter rather than a gauge of
what is left, because a remaining figure would have to be read from the database inside a callback a meter invokes on
its own schedule. Two instruments carry the truncation rather than one, because one enormous message and a thousand
slightly oversized ones need different answers from an operator: how often the ceiling binds, and what raising it would
cost. Embedding generation records what each
ceiling bounds.
The backfill over mail stored before a profile existed publishes its own family beside that one, under
mailfathom.embedding.backfill.*: how many messages awaited embedding when the current sweep began, how each bounded
run ended, and how many messages it cut into passages, brought up to date, and gave vectors to. The instruments are
separate and the tag keys are shared, because a rate an instance settles at and a finite amount of work an operator
started are different questions about one provider bill.
Embedding backfill names each of them, and says why the
outstanding figure is a sweep old rather than live.
A model change publishes two more, mailfathom.embedding.generation.switches and
mailfathom.embedding.generation.removed: the moment a generation being built became the one searches are answered
from, and the vectors of the one it replaced going away in bounded batches. Both are counters rather than a gauge over
which generation is current, because an identifier is a dimension of unbounded cardinality for a value the switch's own
log line already carries. Changing the embedding model is what those two are read against.
Spam classification publishes two counters, and they exist because what they describe leaves no other trace. Where
classification is switched on, a message it has not decided about yet is not chunked, not embedded, and not offered to
the rule set — so a deployment withholding everything and a deployment with nothing to do publish exactly the same
absence of embedding and rule activity. mailfathom.spam.derived_work.admissions is what separates them: one
measurement per decision, tagged with mailfathom.spam.admission as one of admitted, withheld_as_junk,
awaiting_classification, released_as_unclassifiable, or released_after_waiting. The last is the reading worth
alerting on, because a deployment releasing mail on the wait rather than on a verdict is one whose classification is not
running. mailfathom.spam.derived_work.discarded counts the passages removed when a junk verdict arrived after they had
already been cut, and publishes nothing where a verdict removed none. Both are counts, the tag is a closed set of
MailFathom's own names, and nothing about a message reaches either. Junk is kept out of what a deployment derives from
mail holds what each answer
means and what the wait is.
Each outbound AI provider publishes what its last call established about it, as mailfathom.ai.provider.health tagged
with mailfathom.ai.provider.role — embedding or chat. The two roles carry one measurement each rather than one
combined figure, because an instance may hold a working embedding provider and a failing chat one and the two ask
different things of an operator. The value is the state's own number rather than its name, since an instrument's value
has to be one: 0 nothing called yet, 1 serving, 2 unavailable and worth waiting out, 3 failing for a reason a
credential or a declaration has to change. A role nothing has called publishes no measurement at all, so a flat line
always means a provider that is being watched rather than one nobody configured. Chat
generation records what each state means and
why nothing probes a provider to produce one.
A change between two of those states is also written to the log — at Warning when a provider stops answering and at
Information when it answers again — carrying the role and the two states and nothing else. The gauge says what is true
now and the record says when it stopped being true, which is the question the state itself cannot answer because it
carries no age. Only a change is written: every provider call records a state, so a record per call would scale the log
with the mailbox instead of with what an operator would act on. A first call that succeeded is the one change that is
not written, because it restored nothing; a first call that failed is.
Every tool call is counted and timed. mailfathom.mcp.tool.calls and mailfathom.mcp.tool.call.duration carry
mailfathom.mcp.tool with the tool's name and mailfathom.mcp.tool.outcome with one of succeeded, tool_error,
cancelled, protocol_error, refused, or failed. They answer what a span cannot: how often a tool is called, how
its duration is distributed, and whether either is moving — which is how a regression in one tool arrives, long before
anybody correlates the individual complaints it produces. The figure is the same one the record of that call carries,
taken from the same measurement rather than from a second timing path.
The three failing outcomes are apart on purpose. tool_error is an answer the tool diagnosed and reported, refused is
a MailFathom error code the caller can act on, and failed is the generic code a call gets when nothing diagnosed it;
a deployment answering every call with a refusal and one that is broken would otherwise read the same. cancelled is
ordinary traffic on this surface — an impatient client, not a fault — and protocol_error is a JSON-RPC error the
transport reported.
The tool dimension is the one thing here a caller chooses, so it is bounded by construction: a name is used only when
this surface publishes a tool answering to it, and anything else is measured as (unpublished). The log line beside it
keeps the name the caller sent whenever its shape is safe, because somebody diagnosing a client's mistake needs to read
it; a metric dimension cannot afford the same, since a client calling list_email in a loop would otherwise mint a time
series that never goes away. Nothing else about a call reaches either instrument — not an argument, not a filter value,
not a mailbox, not a result.
One counter belongs to no feature at all. mailfathom.persistence.commits counts every local write transaction this
process completed, tagged with mailfathom.persistence.commit.outcome as committed or concurrency_conflict. An
optimistic concurrency conflict is an expected branch rather than a failure — the retry policy commits again from a
fresh read — so a conflict it resolves leaves no other trace, and the only one that surfaces today is the conflict
nobody resolved, which arrives as a single exception after every allowed attempt was spent. The rate is what separates a
deployment where two writers race constantly from one where they never meet, and it is the reading that says a bound
wants raising before anybody sees that exception. Both outcomes are counted because a rate needs the writes it is a rate
of, and the denominator is MailFathom's own sessions rather than EF Core's count of every SaveChanges this process
issues. What was written is nowhere on it: a session covers whatever a use case staged, so any dimension naming it would
eventually name mail.
What a request-path trace contains
A tool call arrives with a span from the request pipeline and leaves a set of database spans behind it, and neither of those says which use case ran in between. Every read the MCP surface serves therefore opens a span of its own, named after the use case rather than after the tool — the tool's own name is already on the SDK's span above it, and a second entrypoint over the same use case is work of the same kind:
| Span | The read it reports |
|---|---|
read_account_directory |
Which accounts this deployment serves, and how current the local copy of each is |
list_mailbox_timeline |
One bounded page of the stored email timeline |
search_mailbox |
One window of a ranking over the stored emails |
read_email_content |
The stored content of the emails one call named |
answer_mail_question |
One answering run, described in full below |
The nesting is what they are for. Each is started inside the protocol span, so it is that span's child, and the database
commands and content-store reads it issues are its children — which is what separates a tool call that was slow in the
use case from one that was slow in a query, and both from one that spent its time waiting on a scanner. A search made
inside an answering run is reported by that run's span instead of opening a second search_mailbox beside it, so the
same work is never counted twice.
| Tag | What it carries |
|---|---|
mailfathom.mailbox.read.results |
How many accounts, summaries, matches, or emails the read returned |
mailfathom.mailbox.read.outcome |
How it ended: succeeded, cancelled, or failed |
cancelled is deliberately not failed. A client that disconnects mid-read is ordinary traffic on this surface, and
counting it as a failure would make an impatient assistant read as a broken deployment. A read that returned nothing is
succeeded with a count of zero: matching nothing is a normal answer everywhere here, and the outcome describes the
read rather than what the mailbox held.
For read_email_content the count is the emails that were served rather than the emails that were named. The gap
between the two is the whole of what that number can say — a call naming ten identifiers and answering for one is a
caller working from a listing that has moved on.
Beneath a read, one span answers a question the database span cannot. read_stored_email_content is opened
wherever raw MIME is read out of local storage, and carries mailfathom.mail.content.bytes with the size of what came
back and mailfathom.mail.content.found with whether anything was there. Every other query this deployment issues
returns columns sized like a row; this one returns a whole message, so a command duration on its own cannot tell a
forty-megabyte message from a two-kilobyte one. An email whose content was never stored reports found as false and no
size, which is an answer rather than a failure.
Four instruments answer over all of those reads what that span answers about one, and cover the write beside it.
mailfathom.mail.content.read.bytes and mailfathom.mail.content.write.bytes are how large the messages moving through
the store are, and mailfathom.mail.content.read.duration and mailfathom.mail.content.write.duration are how long
each took, tagged with mailfathom.mail.content.outcome as found, absent, stored, discarded, or failed. All
four are distributions rather than totals, because what an operator acts on here is the tail: one enormous message and a
steady stream of ordinary ones cost the same in a sum and mean entirely different things. A read of an email whose
content was never stored is timed and not sized, since a zero there would pull the distribution towards a message that
never existed; a read or a write that threw is timed under failed, so a store that started failing does not read as one
nobody is using. The write is measured and deliberately not spanned — it happens once per stored message inside a folder
run that already has a span, so a span apiece would put one per synchronized email into a trace store to say what the
histogram says better.
A write is published under the ending of the transaction that staged it, which is what separates stored from
discarded. Raw MIME is staged inside an optimistic-concurrency attempt, and an attempt that loses the race is run
again from the beginning in a fresh transaction — so a write counted where it happens would report one message as two
stored ones, and would do it in exactly the deployment under contention that mailfathom.persistence.commits is being
read about. stored is therefore the count of messages this deployment holds, discarded is payload it carried and
threw away, and a discarded rate that is anything but negligible is the same finding as a conflict rate that is,
priced in bytes.
One more span belongs to no request at all. The extraction backfill opens backfill_email_extraction once per
bounded pass, which is what tells work an interval caused apart from work a caller caused — without it the pass appears
as parentless database commands competing with the requests around them. It carries
mailfathom.mail.extraction.backfill.extracted, …unreadable, and …missing_content as the counts the pass reached,
…remaining as whether any stored email still awaits extraction, and …outcome as one of succeeded, deferred,
failed, or interrupted. deferred is a competing writer the pass could not resolve against and interrupted is
shutdown; neither is a failure, and the next interval resumes from the committed position in both cases.
That pass publishes a family under the same names, because the question an operator has about a backfill — will this
finish — needs a rate and a remaining amount side by side, over passes rather than within one.
mailfathom.mail.extraction.backfill.extracted, …unreadable, and …missing_content count what the passes moved, and
…run.duration times each pass under the same four outcomes the span carries, so its own count is how many passes there
were. Each counter is added to only when it moved, so an instance with nothing left to extract is not a stream of zeroes
indistinguishable from one working through a mailbox.
mailfathom.mail.extraction.backfill.outstanding is what those counters are working through: how many stored emails
still awaited extraction when the most recent pass ended. It is fed once per pass rather than measured when a collector
asks, because it is a count over every message the walk still owes work on and answering it inside the meter's callback
would put that scan on whatever interval a collector happened to be configured with. It is measured after the pass
rather than before it, so what a pass moved and what it left behind describe the same moment — and because the last pass
of a backfill is the one that finds nothing to do and then ends the worker, which would otherwise leave the figure it
started with published for the life of the process.
Two readings of it are worth knowing. A backlog that stops falling while the extracted counter keeps rising is a walk finding new work as fast as it does old — a mailbox still synchronizing rather than a backfill that has stalled, and the two are only separable because both figures are published. And a small figure that never reaches zero is not a stall either: a message no reader can parse, and one whose raw MIME is no longer there, never gain an extraction, so they stay in this count while the walk correctly reports nothing left to attempt. The counters beside it say how many of each the passes stepped over.
Nothing on any of these spans is derived from a message. There is nowhere on them to put a query text, a filter value, a cursor, a subject, an address, or a stored identity — the values are counts, sizes, and closed sets of MailFathom's own words, which is a cardinality rule as much as a privacy one.
Durable background work
The queue of durable background work publishes six instruments, all broken down by mailfathom.job.type — the job
type's own name, which is the same word the log line, the span, and the stored row use. Nothing else about a job reaches
any of them: not its identifier, not the idempotency key it was enqueued under, not the account it belongs to, and not
the reason recorded against a failure. The key is the one that would carry mail, because it is composed of folder
aliases and message occurrences; the reason is unbounded in the way a metric dimension may not be. Both are read from
the queue itself, through the commands administering a deployment
describes.
mailfathom.jobs.attempts counts every attempt at a job and mailfathom.jobs.attempt.duration records how long each
one took, both tagged with mailfathom.job.outcome as one of succeeded, handler_failed, handler_missing,
timed_out, released_for_shutdown, or lease_lost. The last two are counted like any other attempt on purpose: both
occupied a worker and neither is a failure of the work, so a rolling deployment reads as released attempts rather than
as an unexplained gap in the count.
mailfathom.jobs.retries counts the failed attempts the queue scheduled again, tagged with the outcome that failed. It
is a separate instrument rather than a tag on the attempts, because it answers the question that separates an instance
that is busy from one that is failing and trying again — a retry rate rising while the attempt rate holds steady is work
being repeated rather than work arriving.
mailfathom.jobs.dead_letters counts the jobs nothing will attempt again, tagged with the outcome that ended each one
and with mailfathom.job.failure as transient or permanent. This is the one worth alerting on outright: a dead
letter is claimed by nobody and delays nothing, so it is invisible everywhere else and stays where it is until an
operator acts. The classification says which act is right — permanent names something to fix before a retry could do
anything, and transient names a dependency that stayed broken for longer than the queue was willing to wait. It
carries that dimension and the other three instruments do not, which is why it is an instrument of its own rather than a
tag on the attempts.
mailfathom.jobs.queue.depth reports how many jobs of each type are waiting to be claimed, as the worker last measured
it. Waiting is the pending state alone — a job a worker holds is running, and what bounds that is Jobs:MaxConcurrentJobs
rather than the queue's depth — which is the same reading Jobs:MaxQueueDepthPerType is applied against, so the depth an
operator watches and the depth an enqueue is refused at are one number. Two things follow from that. It saturates at
Jobs:MaxQueueDepthPerType, because the count stops there so that measuring costs the same on a queue of a thousand and
on a queue of a million; a reading sitting at the bound is a queue already refusing work as backpressure. And it is
measured at most once per Jobs:PollInterval by the worker rather than read live, so an instance draining a backlog
pass after pass pays for it no more often than an idle one. A type whose queue emptied reports zero rather than its last
non-zero depth; a type nothing has ever measured is absent from the gauge entirely, so a flat zero always means measured
and empty.
mailfathom.jobs.schedule.dispatches counts what a recurring dispatch decided about one schedule, tagged with
mailfathom.job.outcome as one of seeded, not_due, dispatched, already_dispatched, previous_run_in_flight, or
refused_at_capacity. It is one measurement per schedule per pass, which is at most once per Jobs:PollInterval, and
seeded appears once per schedule ever: the first pass that sees a declaration records where it starts counting from
rather than treating every occasion since the epoch as missed.
mailfathom.jobs.schedule.skipped_occurrences counts the scheduled occasions that were deliberately not run, under the
same outcome tag. A due time that passed while the process was down, while the queue was full, or while the schedule's
previous run was still going is skipped rather than replayed, so this is the only place those occasions are visible at
all — nothing else records a walk that did not happen. A steady non-zero rate is worth alerting on: under
previous_run_in_flight it says a schedule asks for a mailbox walk more often than the walk finishes, and under
refused_at_capacity it says Jobs:MaxQueueDepthPerType is turning scheduled work away. Neither carries the schedule's
own identity, which is composed of an account and a rule name and would grow with the configuration; which schedule is
behind is read from the log line the worker writes beside the measurement.
An instance with Jobs:Enabled switched off, or one with no registered handler, publishes none of the six: its worker
does not start, so it neither runs work, dispatches a schedule, nor measures the queue. The depth of a queue that
instance is not draining is somebody else's replica to report.
What a synchronization cycle emits
No instrumentation package exists for the mail library, so without what follows the part of MailFathom that spends the most wall-clock time would publish nothing of its own at all. Two spans and eight instruments answer the two questions an operator opens a dashboard with: is this account still synchronizing, and if it is slow, which part of it is.
One account's cycle opens synchronize_account, and each folder it works opens synchronize_folder beneath it.
That nesting is the whole point of the pair — a cycle whose duration doubled is attributable to the folder it doubled
in rather than to the account as a whole — and the records the same cycle already logs carry the trace and span
identifiers of whichever of the two they were written inside, so a count in a log line and the span it belongs to are
one thing.
| Tag | Where | What it carries |
|---|---|---|
mailfathom.mail.account |
Both | MailFathom's own configured alias for the account |
mailfathom.mail.folder |
Folder | MailFathom's own configured alias for the folder |
mailfathom.mail.sync.outcome |
Both | How it ended: succeeded, failed, interrupted, and for a folder also alias_unresolved or alias_ambiguous |
mailfathom.mail.sync.failure |
Folder | What stopped it: concurrency_conflict, mail_server_unavailable, or unexpected |
mailfathom.mail.sync.folders, …folders.failed |
Cycle | How many folders the cycle scheduled, and how many did not complete |
mailfathom.mail.sync.stored, …skipped |
Folder | What the folder stored with its content, and what it recorded from the envelope alone |
interrupted is deliberately not failed. Shutdown is what produces it, and an account backed off for every restart
would be an account approached less often for having been stopped. An alias that named no single advertised folder is
kept apart for the same reason: it is a configuration mistake an edit remedies, so it is an outcome rather than a
failure, and it counts against nothing.
| Instrument | What it answers |
|---|---|
mailfathom.mail.sync.run.duration |
How long one account's cycle took, by account and outcome |
mailfathom.mail.sync.emails.stored |
Messages a folder run stored with their content, by account and folder |
mailfathom.mail.sync.emails.skipped |
Messages it recorded from their envelope alone because they exceeded the size limit |
mailfathom.mail.sync.failures |
Folder runs that did not complete, by what stopped them |
mailfathom.mail.sync.backoff |
How long each account waits before its next run, which is its configured interval until a run fails |
mailfathom.mail.sync.consecutive_failures |
How many of that account's runs failed in a row |
mailfathom.mail.sync.runs.queued |
Account cycles waiting for one of the slots that bound how many accounts run at once |
mailfathom.mail.sync.runs.active |
Account cycles holding one of those slots |
The counts are separate from the byte volume above rather than a second view of it: one message is anywhere between a kilobyte and the size limit, so how much a mailbox costs and how much of it has arrived are different questions.
The wait and the failure count are published together because neither is readable alone — a wait says nothing about
health without the interval it is being compared against, and a failure count says nothing about when the server will
next be approached. Both are republished on every pass rather than only while an account is backing off, so an account
that recovered stops reporting the wait it was deferred by instead of holding it, and an account nothing supervises any
more stops reporting altogether. A rising consecutive_failures against a flat runs.active is the reading worth
alerting on: an account being deferred rather than run.
The queue depth is the wait for a slot, which is why the cycle's span opens only once the account holds one — a
duration that included the wait would report the accounts in front of it as this account's own work.
runs.queued standing at the account count while runs.active sits at the configured bound is a pipeline saturated by
that bound rather than an idle one, which is a distinction nothing else here makes.
Nothing published by any of this is derived from a message. The dimensions are the two configured aliases and closed sets of MailFathom's own words, and the values are counts and durations — no UID, no message identifier, no address, no subject, and no remote folder path, each of which would open a time series per message or per person. The mail library itself reaches none of it either: every IMAP client this deployment opens is constructed without a protocol logger, so the commands, responses, and payloads of a session are written nowhere for a log level or a setting to expose, and no configuration key exists that could attach one.
Answering spend
Answering a question sends mail to a model provider on demand, so what it costs is published while it is being spent
rather than at the end of a billing period. mailfathom.answering.runs counts the questions this deployment was asked
to answer, tagged with mailfathom.answering.outcome — admitted or refused — so a ceiling that is met constantly
reads as a ceiling to raise or a client to look at rather than as an absence of questions. mailfathom.answering.tokens
counts what the provider reported those runs consuming.
Beside them, mailfathom.answering.period.runs and mailfathom.answering.period.tokens report how much of the current
period is already spent. The counter and the gauges answer opposite questions — how often the ceiling was reached, and
how close the deployment is to reaching it now — and neither is visible from the other.
An endpoint that reports no usage advances neither token figure, which is why the run and period ceilings exist in a
call-count form as well. Mail answering § What one question may
spend holds the ceilings these are read against, and
MailAnswering the keys.
What a run records
Those instruments describe what answering costs across a deployment. One span describes a single run, and it is the only
place a slow or degraded question is attributable to itself. Every run opens answer_mail_question on the MailFathom
activity source, inside the MCP tool call it happened in — so the SDK's own span for the call is its parent, the
provider calls the run makes are its children, and its duration is the run's own.
| Tag | What it carries |
|---|---|
mailfathom.answering.endpoint |
The alias of the chat endpoint the run was conducted through |
mailfathom.answering.instructions_version |
The version of the instruction the run was conducted under |
mailfathom.answering.candidates |
How many candidates the run's lookups ranked, before any relevance filtering |
mailfathom.answering.candidates.relevant |
How many of those survived being judged, which equals the figure above where nothing judged them |
mailfathom.answering.passages |
How many passages reached the model |
mailfathom.answering.outcome |
How the run ended: Answered, AnswerEmpty, ProviderFailed, RunBudgetExhausted, Cancelled, or Failed |
mailfathom.answering.degradation |
None, RetrievalCeilingReached, RelevanceFilterFellBack, or both |
Three counts rather than one, because they narrow for three different reasons and only the gaps between them say anything: what the queries resembled, what the second pass decided actually answered, and what the run's own ceiling on retrieved mail then allowed to leave the process. A dashboard showing only the last cannot tell a question that found little from one that was stopped from sending much.
The degradation is one bounded tag rather than a log line, which is what lets a degraded run be counted and alerted on rather than read about. It is a set because the two genuinely compose, and it is separate from the outcome because failing is an ending while degrading is what a run that reached an ending did on the way.
A run that failed publishes exactly as one that answered. That is deliberate: the run that ended badly is the one an operator most needs attributed, and a report built only from a successful answer would be silent about it.
Nothing here republishes consumed budget; the instruments above own that, and a second one for it would be duplication. Nothing here carries a question, an answer, a query the model wrote, a retrieved passage, or a message identifier either — which is a cardinality rule as much as a privacy one, and is why which messages a run read is kept in a durable record instead. Mail answering § An account can keep a record of what a question read describes that record and why the split falls where it does.
One counter beside the span answers a question about the record rather than about the run.
mailfathom.answering.audit.refused_appends counts the entries a finished run owed and the record could not be given,
by endpoint alias, alongside a warning naming the run. It is zero on a deployment where no account keeps a record,
because nothing is owed there. On one that does it is the reading worth alerting on outright: the question was answered
and the history of what it read is missing, which is exactly the gap an audit cannot recover afterwards. It exists
because writing an entry may never fail the answer it describes, so swallowing the failure is only defensible while it
is counted.
What guarding an egress point publishes
Sensitive-content scanning publishes five instruments, all of them tagged with
mailfathom.sensitive_content.egress_point — chat_prompt, hosted_embedding_input, mcp_snippet, or
mcp_email_content. The egress point is on every one of them because it is what an operator acts on: "something was
redacted" says nothing, while a scanner finding credentials in retrieved extracts and nothing in subjects, or adding two
seconds to a listing and nothing to an embedding call, is where a category list or a bound gets changed. It is also how
the cost of scanning a whole message is read: mcp_email_content is the point a reader waits on, kept apart from the
snippet scanning that would otherwise average it away.
Every one of these series counts a guarded value rather than a guarded operation, because a value is what a scan
runs over. One get_email_content call scans each body representation, the subject, and each display name it publishes,
for every email it names, so what the caller waits for is the sum of the durations that call recorded rather than any
one of them — read the count beside the duration before sizing the feature or alerting on a percentile.
| Instrument | What it answers |
|---|---|
mailfathom.sensitive_content.guarded |
How many texts were scanned before they crossed out of the deployment |
mailfathom.sensitive_content.findings |
How many detections were replaced, split by mailfathom.sensitive_content.category |
mailfathom.sensitive_content.omitted |
How many characters the analyzed ceiling dropped rather than hand on unscanned |
mailfathom.sensitive_content.refusals |
How many operations a scanner that could not answer refused, by mailfathom.sensitive_content.scanner |
mailfathom.sensitive_content.scan.duration |
What scanning added to one guarded operation |
The findings are split by category rather than totalled because which kind of material a mailbox is producing is what decides whether a category list is right, and a total says only that the feature is switched on. The omitted count is recorded only when the ceiling actually cut something: a zero on every guarded text would make the series say the ceiling is in play on ordinary mail, which is the one question that instrument exists to answer. All five read zero on a deployment with both switches off, because nothing is constructed there.
Nothing published here is mail or derived from it. The three tags are MailFathom's own closed sets, and the values are counts and durations — never a rule's match, a position, a message identity, or any part of what was found, each of which would put the credential in the telemetry written to prove it never left. Sensitive-content scanning names the points themselves and what a refusal does to each.
What redacting a derived write publishes
The derived path publishes five instruments of its own, under mailfathom.sensitive_content.derivation.*. They carry
no egress point, because a derived write crosses nothing — the text is redacted on its way into this deployment's own
store — so what separates these series from the guarded ones is their names.
| Instrument | What it answers |
|---|---|
mailfathom.sensitive_content.derivation.redacted |
How many texts were scanned before they were stored, chunked, and embedded |
mailfathom.sensitive_content.derivation.findings |
How many detections were replaced in them, split by mailfathom.sensitive_content.category |
mailfathom.sensitive_content.derivation.omitted |
How many characters the analyzed ceiling dropped rather than store unscanned |
mailfathom.sensitive_content.derivation.refusals |
How many extractions a scanner that could not answer refused, by mailfathom.sensitive_content.scanner |
mailfathom.sensitive_content.derivation.duration |
What scanning added to deriving one message |
The duration is the measurement the feature is judged on: it is paid once per message rather than once per request, and it lands on synchronization and on the extraction backfill, which is where a mailbox being indexed for the first time or re-derived after a switch either keeps up or does not. The refusals are how much of a mailbox is being left underived, and every refused message is retried on a later run rather than lost. All five read zero on a deployment with both switches off, which constructs no detector on this path at all.
What no signal carries, and what holds every signal to it
Everything above is one rule, and it is a cardinality rule as much as a privacy one. Counts, sizes, durations, outcomes, error codes, and MailFathom's own configured account, folder, and provider-endpoint aliases are permitted. Mail content, an address, a subject, a remote folder path, a message identifier, a UID, a search term, a credential, and model prompt or completion text are not — every one of them would open a time series per message or per person, quite apart from putting personal data in a span store. It governs a name as much as a value: a span, an instrument, and a dimension are all named after the operation or the quantity they report, never after anything the operation saw.
The rule is the same one that governs the log lines, and the aliases are the one place a value an operator wrote reaches an exporter. That is deliberate: an alias is MailFathom's own word for an account, a folder, or an endpoint, it is bounded by the size of somebody's configuration, and a dashboard without it cannot say which account is behind.
What makes it a contract rather than a convention is that it is asserted over the emitted surface as a whole rather than per feature. A rule checked once per publisher is a rule the next publisher is not covered by, and a telemetry surface grows one publisher at a time — so the unit-test suite drives every publisher the two boundaries that own one hold, through a listener over MailFathom's activity source and meter, and holds what came out against four claims:
- No span name, instrument name, or dimension key is named after a word from the refused list, matched whole segment by
whole segment. That is what separates
token, which names a credential, fromtokens, which is how many a model consumed. - Every instrument name and dimension key sits under the single
mailfathom.namespace in lower case, which is what keeps a dimension from being minted out of a value at run time. - Every span is named as one lower-case phrase, so a span name is an operation rather than anything it read.
- Every string the drive hands a publisher is a sentinel, one per class of input — a configured alias, text a caller sent, text read out of a message — and the assertion is where each was allowed to surface. A caller's text and anything mail-derived may reach nothing at all; an alias may reach the dimensions named for it and no others.
Two things keep that from decaying quietly. Within each of those two boundaries a publisher is found by what it holds — an instrument field, or a declared span name — rather than by where it lives, so a publisher nobody added to the drive fails the suite instead of going unasserted. And the names every assembly of this deployment declares, the host's own included, are read and held against the same vocabulary, which reaches what no drive does: the span the extraction backfill worker opens, and a dimension only a failure path sets.
How much of a trace is recorded
Sampling is a decision rather than a default to inherit, and MailFathom's is parent-based always-on: every trace this process starts is recorded, and a trace it did not start keeps the decision the caller already made.
The always-on half is what a mailbox-sized workload is worth. The volume is bounded by one deployment's accounts and one assistant's tool calls rather than by public traffic; a folder run whose duration doubled is attributable only if the run before it was recorded too; and export is off unless an endpoint is configured, so the default costs an unconfigured deployment nothing at all. The parent-based half keeps a head decision made upstream from being overturned here — the MCP surface continues a caller's trace, and a caller that dropped it is not asking for a fragment of it back.
A deployment paying a collector per span is the case that wants the other answer, and it is the operator's to give:
| Variable | What it does |
|---|---|
OTEL_TRACES_SAMPLER |
always_on, always_off, traceidratio, parentbased_always_on, parentbased_always_off, or parentbased_traceidratio. Unset is the default above |
OTEL_TRACES_SAMPLER_ARG |
The ratio the two traceidratio samplers use, between 0 and 1 |
Both are read by the OpenTelemetry SDK itself, and MailFathom sets its own sampler only when the first is unset — the SDK ignores its own configuration when a sampler was set programmatically and reports the fact to an event source nobody is listening to, so setting one unconditionally would answer an operator's variable with silence.
Three things follow from the SDK reading these itself. OTEL_TRACES_SAMPLER_ARG on its own does nothing, because no
sampler is a ratio one until the first variable names it. A value the SDK does not recognize — a hyphen where the name
takes an underscore — is reported to that same event source and falls back to parentbased_always_on, which is what
this host would have set anyway, so a misspelling reads as the variable having been ignored rather than as an error.
And both have to be environment variables, not configuration keys, for the reason the exporter switch below gives —
a start that writes any OTEL_* name into a file or an argument fails naming it.
Health and liveness probes are excluded from tracing before any sampler sees them. A probe arrives every few seconds for the life of the process and says the same thing every time, so on a deployment exporting to a collector it pays for, the polling would otherwise be most of the bill.
The one switch: OTEL_EXPORTER_OTLP_ENDPOINT
The OTLP exporter is attached only when the OTEL_EXPORTER_OTLP_ENDPOINT environment variable is non-empty. Unset,
the instruments still exist and nothing collects them: no telemetry leaves the process, and the console remains the
only place logs go. There is no MailFathom-specific telemetry key — the exporter reads the standard OpenTelemetry
variables itself:
| Variable | What it does |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
The OTLP destination; setting it is what attaches the exporter |
OTEL_EXPORTER_OTLP_PROTOCOL |
grpc (the default) or http/protobuf |
OTEL_EXPORTER_OTLP_HEADERS |
Headers sent with every export, which is where a collector's credential travels |
OTEL_EXPORTER_OTLP_TIMEOUT |
The per-export timeout |
OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES |
The resource identity the records carry, except the version and the revision above |
The variable has to be an environment variable, not a configuration key. That is deliberate, and host startup telemetry records why: the bootstrap pipeline that reports startup failures is built before configuration exists, reads the same variable, and exports each record synchronously — so the decision to export and the destination being exported to can never disagree between the two pipelines, and a start that fails while configuration is loading is still reported to the same place as everything else.
Writing any OTEL_* name into appsettings.json, a provisioned configuration file, or a command-line argument fails
startup naming it, rather than leaving a deployment exporting to nowhere while its own file says otherwise;
environment-only settings states that rule and the two other
families it covers.
Local development: the Aspire dashboard
The AppHost orchestration is where the switch is flipped for you. When src/AppHost starts a project resource, Aspire
injects OTEL_EXPORTER_OTLP_ENDPOINT — together with the authentication header its dashboard expects — pointing at
the dashboard's own OTLP ingestion endpoint. The host needs no telemetry configuration at all:
dotnet run --project src/AppHost/AppHost.csproj
The AppHost prints the dashboard address, including a one-time login link, as it starts. The dashboard then shows, per
resource: the console output, the structured logs with their named properties, the traces — an MCP request, the
database commands it issued, the outbound calls beside them — and every metric above, including the Polly meter's
resilience events and the Npgsql instruments.
Two properties keep this arrangement honest:
- It is per run and in memory. The dashboard retains nothing across restarts. Telemetry produced from a developer's own synchronized mail never lands in a store that outlives the session.
- It is local. The OTLP endpoint Aspire injects is a loopback address with a per-run key, so nothing is exported off the machine.
The startup records from the bootstrap pipeline arrive in the same dashboard, because Aspire sets the same variable that pipeline reads — a host that fails while binding its options is therefore diagnosable from the dashboard's structured logs, not only from the console.
Running locally with Aspire covers the rest of the orchestration: the resource start order, the PostgreSQL data volume, and the migration resource that applies the schema before the host starts.
Deployments export nothing by default
Neither the Compose deployment nor the Helm chart sets any OTEL_* variable. That is a privacy default, not a gap:
MailFathom's telemetry describes activity around personal mail — account aliases, folder aliases, tool-call rates,
failure codes — and even without content, that stream identifies people and habits. Where it flows is therefore a
decision the operator takes explicitly, never one a deployment asset takes for them.
To export from a deployment, set the standard variables on the MailFathom container or service — an OTLP collector
address, its credential in OTEL_EXPORTER_OTLP_HEADERS — and treat the destination as part of the deployment's trust
boundary: it stores what the log contract permits, which still includes MailFathom's own names for accounts and
folders, durations, and error codes. Content never enters telemetry, so a collector never holds mail — but a
collector outside your control is still the wrong place for a mailbox's activity pattern.
The console log needs none of this. A container's log driver, journalctl for a native service, and
docker compose logs all read the stream that is always written, and the startup records land there
synchronously whether or not any exporter is attached.
What that stream looks like is configuration rather than a fixed shape. Logging:Console selects the formatter —
json where something parses the lines, systemd where journalctl should read the level rather than print it as
text — and carries a level filter of its own, so a noisy container log can be quietened without changing what the
exporter sends to a collector. The Logging section states those keys, together
with the one asymmetry worth planning for: the startup records are written before that configuration exists and keep
the default format whatever it selects.