Table of Contents

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 and scopes included, beside the console output that is always on. 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 four 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

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 three 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, and MCP protocol operations, correlated end to end: the trace a request arrives with is the trace its log records and its failure diagnostics carry. 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.

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.

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 — 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 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, and today two subsystems do.

Every change MailFathom makes to a remote mailbox opens a span named after the mutation, and is counted along with how long it took, 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.

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.

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.

What such a signal may carry is bounded by the same rule that governs the log lines, and it is a cardinality rule as much as a privacy one. Counts, sizes, durations, outcomes, error codes, and MailFathom's own configured account and folder 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.

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

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.