Architecture Decision Record
ADR-041: Observability and Telemetry Strategy
Status
Accepted (2026-07-10). Amended (2026-07-23) to document the Telemetry:DisableHttpClientMetrics and
Telemetry:DisableRuntimeMetrics cost knobs and to correct the meter/activity-source literal
citations. Amended (2026-07-25) to describe how the CQRS logging decorators actually record duration
(a per-path RecordDuration helper, not a finally) and to rebase the decorator outcome-tag, outbox
poll-span, and OutboxProcess parent-context citations onto their current lines. Amended
(2026-07-28) to rebase the Aspire service-defaults citations (the sampling and cost-knob helpers, the
two exporter reads) and the outbox dead-letter increment onto their current lines. Amended
(2026-08-01) to rebase the ASP.NET Core/HttpClient tracing citation, the CQRS duration-literal
citations, the outbox poll-span-filter clear statement, the outbox meter/counter/activity-source
declarations, the dead-letter increment call site, the poll-span open site, the OutboxProcess
span-start site, and the correlation-id response-header citation onto their current lines. Amended
(2026-08-07) to record that the outbox meter and dead-letter counter now live in a dedicated
OutboxMetrics type rather than in OutboxProcessor, to document the second dead-letter increment
on the retries-exhausted path and the reason tag that separates the two, and to rebase the Aspire
service-defaults, CQRS metric-literal, and outbox span citations onto their current lines. Amended
(2026-08-18) to record two new meter families (MMCA.Common.OutputCache, MMCA.Common.BestEffort), to
correct the meter inventory this record has been under-reporting, and to note that the correlation id
now starts one hop earlier, at the Gateway; see the Revision (2026-08-18) at the end. Amended
(2026-08-31) to record the application logging pipeline (Serilog as one additional provider beside the
OpenTelemetry one, never a replacement ILoggerFactory), its level and file-sink policy, the pre-DI
bootstrap logger, and which hosts adopt it; see the Amended (2026-08-31) section at the end. Amended
(2026-09-03): MMCA.Store.UI.Web now calls AddCommonSerilog instead of hand-rolling the same
configuration inline, so the "defaults exist in two shapes" cost that amendment recorded is gone and
eight hosts share one helper; its Program.cs citations and the per-host AddCommonSerilog /
bootstrap-factory line anchors are rebased onto their current lines. Amended again (2026-09-03) to
record the probe-telemetry cost knob (Telemetry:FilterProbeTelemetry, the one knob that is on by
default) and the metric-drop views that make the two metrics knobs authoritative over the Azure
Monitor distro, and to rebase the Aspire service-defaults, CQRS decorator and outbox citations (the
outbox processor and its metrics now live under Persistence/Outbox/Processing/) onto their current
lines; see the "Amended (2026-09-03): probe telemetry" section at the end.
Context
The framework is a modular monolith whose modules extract into standalone services (ADR-008), so
the same telemetry has to make sense whether a request stays in one process or crosses a gateway and
several service hosts. OpenTelemetry auto-instrumentation (ASP.NET Core, HttpClient, the .NET
runtime) gives generic HTTP and runtime signals for free, but it is blind to the two paths that carry
almost all of the framework's own work: the CQRS use-case pipeline (ADR-014) and the outbox
(ADR-003). "How long is this command taking and how often does it fail" and "is the outbox
dead-lettering" are not questions auto-instrumentation can answer.
Two cost forces pull the other way. A deployed fleet polls every relational outbox around the clock, so idle poll spans would dominate Application Insights ingestion if exported, and full-fidelity tracing is the single largest observability line item. The framework needs custom instrumentation where auto-instrumentation is blind, plus knobs that cut telemetry cost without going dark. This cross-cutting observability decision was implemented but named by no existing ADR; this record captures it.
Decision
Standardize telemetry in the shared Aspire service defaults, add framework-specific instrumentation for the CQRS and outbox paths, and expose cost knobs with fail-safe defaults.
One shared telemetry baseline on every host.
ConfigureOpenTelemetry(Source/Hosting/MMCA.Common.Aspire/Extensions.cs:121) wires OpenTelemetry logging with formatted messages and scopes (Extensions.cs:132-Extensions.cs:133), metrics from ASP.NET Core (unconditional,Extensions.cs:139) plusHttpClientand the runtime (each gated behind a cost knob, see below), and tracing from ASP.NET Core andHttpClient, added either with the probe-telemetry filters attached (Extensions.cs:230-Extensions.cs:233) or plain (Extensions.cs:237-Extensions.cs:238) depending on the knob the Amended (2026-09-03) section records. It is called fromAddServiceDefaults(Extensions.cs:41), so a host opts in once and every project in the Aspire model inherits the same pipeline.Custom RED metrics from the CQRS pipeline. A single meter
MMCA.Common.Cqrs(Source/Core/MMCA.Common.Application/UseCases/Decorators/CqrsMetrics.cs:24) publishes two duration histograms:cqrs.command.duration(CqrsMetrics.cs:30) andcqrs.query.duration(CqrsMetrics.cs:36), both in milliseconds. Every path is measured without afinally: each logging decorator routes all three of its exits through a privateRecordDurationhelper, so the measurement cannot be skipped. The command helper callsCqrsMetrics.CommandDuration.Record(...)tagged bycommandandoutcome(Source/Core/MMCA.Common.Application/UseCases/Decorators/LoggingCommandDecorator.cs:80, in theRecordDurationhelper declared at:79) and the query helper does the same forQueryDuration(Source/Core/MMCA.Common.Application/UseCases/Decorators/LoggingQueryDecorator.cs:78, helper at:77). Theoutcometag takescompleted,failed(aResultfailure), orexception, one call site per path (LoggingCommandDecorator.cs:49,:44,:58; the query equivalents atLoggingQueryDecorator.cs:46,:41,:55), so count gives rate, the tag gives errors, and the histogram gives duration. The Aspire host subscribes the meter by literal name (Extensions.cs:200).An outbox dead-letter counter. The outbox instruments live in their own static type (
Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Processing/OutboxMetrics.cs:16), which owns the meterMMCA.Common.Outbox(OutboxMetrics.cs:19) and the counteroutbox.dead_letter.count(OutboxMetrics.cs:41-OutboxMetrics.cs:42).OutboxProcessor(Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Processing/OutboxProcessor.cs) increments it on both dead-letter paths, tagged byevent_typeand by areasonthat tells them apart:type_unresolvablewhen a message's event type cannot be resolved (OutboxProcessor.cs:719-OutboxProcessor.cs:722), andretries_exhaustedwhen a failing message reachesMaxRetriesand drops out of the poll (OutboxProcessor.cs:674-OutboxProcessor.cs:677). The processor's activity source publishes outbox spans under the same name (OutboxProcessor.cs:87); both the meter and the trace source are registered by literal name in the Aspire defaults (Extensions.cs:199,Extensions.cs:210).Correlation-ID middleware ties the request together.
CorrelationIdMiddleware(Source/Presentation/MMCA.Common.API/Middleware/CorrelationIdMiddleware.cs:15) uses theX-Correlation-IDheader (CorrelationIdMiddleware.cs:18), reading it from the request or falling back to the current W3C trace id and then toHttpContext.TraceIdentifier(CorrelationIdMiddleware.cs:32), sets it on the scopedICorrelationContext(CorrelationIdMiddleware.cs:36), and echoes it on the response (CorrelationIdMiddleware.cs:39, inside theOnStartingcallback registered atCorrelationIdMiddleware.cs:37). The CQRS logging decorators stamp that same id into every log scope (read atLoggingCommandDecorator.cs:25, stamped byBeginCommandScopeat:27), so logs, the correlation id, and the trace id line up for one request.Two high-volume metric families gated behind cost knobs, on by default. ASP.NET Core metrics are always wired (
Extensions.cs:139), but the two heaviest AppMetrics contributors on a low-traffic multi-service deployment are conditional.HttpClientconnection and request metrics are added only whenTelemetry:DisableHttpClientMetricsis unset or false (Extensions.cs:148, adding instrumentation atExtensions.cs:168), and .NET runtime metrics (dotnet.gc.*,jit.*,thread_pool.*) only whenTelemetry:DisableRuntimeMetricsis unset or false (Extensions.cs:175, adding atExtensions.cs:187). Skipping the instrumentation is not enough on its own, so each disabled branch also drops the whole meter with aView(Extensions.cs:160-Extensions.cs:164forSystem.Net.HttpplusSystem.Net.NameResolution,Extensions.cs:180-Extensions.cs:183forSystem.Runtime): the Azure Monitor distro adds those meters itself, and aViewapplies to the wholeMeterProviderregardless of which component added them, which is what makes each knob authoritative rather than advisory. Both keys are read byIsInstrumentationDisabled(Extensions.cs:471-Extensions.cs:472), which drops the family only when the value parses as booleantrue; absent, blank, or unparseable falls back to keeping the instrumentation, so a typo cannot silently blind a whole metric family. A deployed host sets one or both totrueto cut ingestion cost; outbound dependency latency is still captured as traces whenHttpClientmetrics are dropped.Head-based sampling as a cost knob, off by default.
Telemetry:TracesSampleRatio(Extensions.cs:261, parsed byTryGetTraceSampleRatioatExtensions.cs:448, which reads the key atExtensions.cs:451) is unset by default, so a host samples everything and behavior does not change. A deployed host sets a ratio in the open interval (0,1) to keep that fraction of traces; the value wraps aTraceIdRatioBasedSamplerin aParentBasedSampler(Extensions.cs:262) so a sampled-in request keeps its whole trace across service boundaries. A key that is absent, unparseable, or outside (0,1) falls back to sample-all (Extensions.cs:452-Extensions.cs:457), so a typo can never silently drop all telemetry.Outbox poll spans are filtered out of export.
OutboxPollFilterProcessor(Source/Hosting/MMCA.Common.Aspire/Telemetry/OutboxPollFilterProcessor.cs:15), registered before the exporters (Extensions.cs:246), clears theRecordedflag on the recurringOutboxPollspan and its children (OutboxPollFilterProcessor.cs:45). The poll query runs inside that span, opened at the top ofFetchCandidatesAsync(OutboxProcessor.cs:413, span started atOutboxProcessor.cs:419, named atOutboxProcessor.cs:75), so steady-state polling does not flood Application Insights. Real outbox work is untouched: each per-messageOutboxProcessspan is started byStartOutboxActivity(called once per message atOutboxProcessor.cs:579, declared atOutboxProcessor.cs:775) under an explicit parent context restored from the message's stored trace and span ids (OutboxProcessor.cs:782-OutboxProcessor.cs:785), span started atOutboxProcessor.cs:787-OutboxProcessor.cs:790, so it is never a child of the poll span.Dual exporters, either or both.
AddOpenTelemetryExportersenables OTLP whenOTEL_EXPORTER_OTLP_ENDPOINTis present (Extensions.cs:363-Extensions.cs:364, the Aspire dashboard sets it, exporter wired atExtensions.cs:368) and Azure Monitor viaUseAzureMonitor(Extensions.cs:376) whenAPPLICATIONINSIGHTS_CONNECTION_STRINGis present (read atExtensions.cs:371-Extensions.cs:372, checked atExtensions.cs:374, and set by the cloud deployment). Both can be active at once (Extensions.cs:359), so local development ships to the Aspire dashboard and production ships to workspace-based Application Insights with no code change.
Rationale
- Instrument only where auto-instrumentation is blind. The CQRS RED histograms and the outbox dead-letter counter cover the two framework-owned hot paths; everything else (HTTP, runtime) rides the free auto-instrumentation, so the custom surface stays small.
- RED at the decorator, not in every handler. The CQRS pipeline already wraps every handler in a logging decorator (ADR-014), so recording duration and outcome there makes metrics a byproduct of a pipeline layer that exists, with no per-handler discipline (the invariant-over-discipline posture, ADR-015).
- A single correlation id with a W3C fallback. Whether or not a client supplies
X-Correlation-ID, one id stitches the logs of a request together and matches the trace, which is what an operator needs first when a distributed call goes wrong. - Cost knobs default to safe. Sampling, poll-span filtering, and the
HttpClient/runtime metric toggles are the levers a FinOps owner reaches for (COST.md), and all fail toward keeping data: sampling is off unless configured, an out-of-range ratio is ignored, only idle poll spans are dropped, and a metric family drops only on an explicit booleantrue(a typo keeps it on). ParentBasedkeeps distributed traces coherent. An extracted-service deployment (ADR-008) needs a sampled-in request to stay sampled end to end; a per-hop random sampler would shred cross-service traces.
Trade-offs
- Custom instrumentation carries a maintenance cost. The Aspire package has no reference to
Application or Infrastructure by design, so the meter and activity-source names are duplicated as
literals (the meter subscriptions at
Extensions.cs:199-Extensions.cs:205and the trace source atExtensions.cs:210, and the sync notes atCqrsMetrics.cs:8,OutboxMetrics.cs:8andOutboxPollFilterProcessor.cs:17). A rename on one side silently stops export until the literal is updated. That is the price of the decoupled package graph. - Sampling trades trace completeness for cost. A sampled-out trace is simply gone; deep debugging of a specific request can miss it. Metrics and logs are unaffected (sampling is trace-only), so RED rates and error counts stay whole even at a low ratio.
- Poll-span filtering hides steady-state outbox activity. The dead-letter counter and per-message
OutboxProcessspans remain, but "is the poller alive and looping" cannot be answered from traces alone, by design (that signal is metrics and the dead-letter counter, not spans). - Cross-service trace continuity depends on stored ids and the parent decision. A linked
OutboxProcesstrace only reconnects when the producer captured the trace and span ids on the message;ParentBasedsampling that dropped the originating trace also drops the linked span. - Exporters and sampling are opt-in per host. A host that sets neither exporter variable emits to nothing, and a misconfigured ratio fails toward sample-all (higher cost) rather than toward silence: the intended bias, but it means a cost surprise is possible where a data gap is not.
Revision (2026-08-18)
Two meters and one hop.
Two new failure counters, each on its own meter. cache.eviction.failed, tagged cache_tag, on
MMCA.Common.OutputCache
(MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheMetrics.cs:19, instrument at
:29-37) counts a cross-service output-cache eviction that failed for one tag
(ADR-026's Revision (2026-08-18)); and besteffort.dispatch.failed, tagged
operation, on MMCA.Common.BestEffort
(MMCA.Common/Source/Core/MMCA.Common.Application/Services/BestEffort.cs:102, instrument at :107-115)
counts a swallowed fire-and-forget side effect, the helper's whole purpose being that the caller does
not see the failure. Both are subscribed in the Aspire defaults
(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:204-205).
The meter inventory in the Decision above is wrong and has been for a while. This record names two
meters, and ADR-087 called MMCA.Common.Broker "a third",
which was already an undercount. The authoritative list is the subscription block itself
(Extensions.cs:199-205), which now carries seven: MMCA.Common.Outbox, MMCA.Common.Cqrs,
MMCA.Common.Idempotency, MMCA.Common.Scheduler, MMCA.Common.Broker, MMCA.Common.OutputCache,
MMCA.Common.BestEffort. Two of them (Idempotency, Scheduler) were never recorded here at all.
Read that block, not this prose, when the question is what the framework exports.
Correlation now starts at the edge. ADR-088 adds a
context-free GatewayCorrelationMiddleware that ensures X-Correlation-ID on the way in and echoes it
on the way out, writing it onto the forwarded request so the service-tier CorrelationIdMiddleware
adopts it rather than minting its own. The Decision's claim that one id stitches a request together
becomes true across the gateway hop, where it previously began at the first service and left the
Gateway's own logs unlinked. No meter, no span, one header, one hop earlier.
Two costs come with it. Both new counters are failure-only, so a healthy system emits nothing on them and a zero is indistinguishable from a host that never wired the feature, which is exactly the shape of signal that goes unnoticed until an incident. And neither is wired to an alert or a runbook section, joining ADR-087's two counters in the gap ADR-062 describes. The duplicated-literal cost this record already records in Trade-offs now applies to seven names rather than two.
Amended (2026-08-31)
The log side of this record. Until now it named only the OpenTelemetry logging call inside
ConfigureOpenTelemetry (Extensions.cs:130); what a host actually WRITES its application log lines
through was undocumented.
Serilog is registered as ONE additional provider, never through UseSerilog(). AddCommonSerilog
(MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Logging/SerilogHostExtensions.cs:48) builds the
framework's logger configuration, publishes it as the global Log.Logger
(SerilogHostExtensions.cs:54), and adds it to the host's existing factory with
builder.Logging.AddSerilog(Log.Logger, dispose: true) (:55). The alternative is a silent-failure
trap no code reading surfaces, which is why the rationale lives on the type itself (:16-:20):
UseSerilog() replaces the whole ILoggerFactory and with it every other provider, including the
OpenTelemetry to Azure Monitor provider AddServiceDefaults wires (Extensions.cs:41,
Extensions.cs:121). A host that calls it publishes no application log line to Application Insights at
all, while its metrics, traces and health endpoints stay green, so the gap reads as a quiet service
rather than as a misconfiguration. Ordering carries the same weight in the other direction: the helper
runs BEFORE AddServiceDefaults() in every host that uses it (for example
MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:100-:101), so the OpenTelemetry
provider joins the factory Serilog is already in. One fitness test pins the invariant: the built
container must contain exactly one SerilogLoggerProvider
(MMCA.Common/Tests/Hosting/MMCA.Common.Aspire.Tests/Logging/SerilogHostExtensionsTests.cs:156-:158).
Level policy and sinks. The minimum level is Debug in Development and Information everywhere
else (ResolveMinimumLevel, SerilogHostExtensions.cs:76-:77, applied at :107), with
Microsoft.EntityFrameworkCore and Microsoft.AspNetCore held at Warning (:108-:109) and a
console sink always (:110). The rolling daily file sink is environment-conditional: added everywhere
except Production (ShouldWriteFileSink, :87-:88, applied at :112-:118), because a production
container writes it to ephemeral disk nothing reads while stdout and the OpenTelemetry provider already
carry the same events, whereas outside Production (local runs and the CI E2E stack) that file is what a
failure gets diagnosed from. A host needing one extra sink or enricher passes the optional configure
hook (:50, invoked at :120) instead of forking the helper.
A bootstrap logger for the pre-DI window. Module discovery runs before the DI container exists, so
there is no ILogger<T> to resolve yet. CreateBootstrapLoggerFactory() (:67-:68) returns a
factory writing to the same global Log.Logger, which each host disposes once startup wiring is done
(MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:122).
Adoption is asymmetric, but there is only one shape of it. Eight hosts call AddCommonSerilog,
each in its own Program.cs: the seven ADC/Store service hosts, ADC Conference (:100), Engagement
(:83), Identity (:96), Notification (:86), and Store Catalog (:68), Identity (:73), Sales
(:85), plus MMCA.Store.UI.Web
(MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:58). Each of the seven service hosts pairs
it with CreateBootstrapLoggerFactory() in the same file (ADC :308, :209, :247, :187; Store
:122, :117, :131); MMCA.Store.UI.Web does not, because it discovers no modules and so has no
pre-DI window to cover. The asymmetry that remains is the three hosts with no Serilog at all: neither
Gateway references it in its Program.cs, and neither does MMCA.ADC.UI.Web. Those three take the
plain OpenTelemetry logging AddServiceDefaults gives them.
Two costs come with it. The invariant is guarded in the framework, not at the consumer: the one
test above runs in MMCA.Common, and nothing in a host's own build stops a new service from reaching for
UseSerilog(), whose failure mode is silence in a place nobody watches for absence. And
Information is not the level that reaches
the queryable store: ADR-098 records the
production thinning that floors the OpenTelemetry logging provider at Warning while Serilog keeps
Information on container stdout, so the two providers this record puts side by side deliberately
carry different volumes.
Amended (2026-09-03): probe telemetry
Health probes were the trace bill. Container Apps liveness and readiness probes, the gateway's
downstream aggregate probes, YARP active health checks and the availability web test accounted for
every AppRequests row in both production workspaces, and their children (the health check's SQL
SELECT 1, the Redis PING, the gateway's HttpClient calls to each backend's /alive) for most of
the AppDependencies rows. None of it carries end-user signal, and none of it is touched by
Telemetry:TracesSampleRatio, because probe spans are exactly what a ratio sampler is asked to keep
proportionally.
A third cost knob, and the only one that defaults to on. Telemetry:FilterProbeTelemetry
(Extensions.cs:35) is read by IsProbeTelemetryFilterEnabled
(Extensions.cs:483-Extensions.cs:484) at Extensions.cs:224. It inverts the fail-safe direction
of the other knobs on purpose: absent, blank or unparseable all mean "filter", and only an explicit
boolean false turns filtering off, for a host debugging its own probes. What a probe path is comes
from one place, HealthEndpointPaths.IsProbePath
(Source/Hosting/MMCA.Common.Aspire/HealthEndpointPaths.cs:29-:33): /alive, /health, and
anything below /health/, case-insensitively.
Two instrumentation predicates plus one processor, because probe spans arrive by three routes.
With the knob on, the tracing setup attaches both filters to the default-named instrumentation
options (Extensions.cs:230-Extensions.cs:233; the unfiltered branch at
Extensions.cs:237-Extensions.cs:238 is plain AddAspNetCoreInstrumentation and
AddHttpClientInstrumentation). ProbeTelemetryFilter.ShouldCollectRequest
(Source/Hosting/MMCA.Common.Aspire/Telemetry/ProbeTelemetryFilter.cs:40) refuses the inbound probe
request span and stamps an mmca.probe marker tag on it (ProbeTelemetryFilter.cs:33, set at
:51), because a refused request never gets its url.path written and its descendants would
otherwise have no way to recognize their own ancestor.
ProbeTelemetryFilter.ShouldCollectOutgoing (:62-:63) refuses outbound probe calls that are not
descendants of any inbound request, the gateway's DownstreamServiceHealthCheck calls and YARP's
active checks, both driven by background timers. The descendants are handled by
ProbeTelemetryFilterProcessor
(Source/Hosting/MMCA.Common.Aspire/Telemetry/ProbeTelemetryFilterProcessor.cs:20), registered only
when the knob is on and, like the outbox poll filter, before the exporters
(Extensions.cs:253): it walks the in-process parent chain (ProbeTelemetryFilterProcessor.cs:52),
matches the marker or a server span whose path, route or display name is a probe (:66-:79), and
clears Recorded plus IsAllDataRequested (:59-:60) at both OnStart (:29) and OnEnd
(:40), since a client span carries no identifying tag yet when it starts. Unlike the two metrics
knobs, these filters need no view: configuring the default-named options also covers the
instrumentation the Azure Monitor distro adds.
Metrics are deliberately untouched (Extensions.cs:222-Extensions.cs:223):
http.server.request.duration, Kestrel and routing instruments keep flowing, so probe traffic stays
on dashboards.
Two costs come with it. "Did the probe pass" is no longer answerable from traces, the same
blindness poll-span filtering already accepts for the outbox, so that question belongs to metrics
and the health endpoints (ADR-025) instead. And this knob fails
toward dropping data while sampling and the two metrics toggles fail toward keeping it: a host that
adds a real route below /health/ has its traces filtered by IsProbePath's prefix match with no
error and no log line.
Related
ADR-003 (the outbox whose dead-letter counter and poll-span filtering this defines), ADR-014 (the
CQRS decorator pipeline that emits the RED histograms as a byproduct of its logging decorators),
ADR-009 (resilience and recovery objectives, configured alongside telemetry in the same
AddServiceDefaults; observability is the diagnostic layer under that posture), ADR-025 (startup
warm-up and readiness gating, whose health-check endpoints are the operational-signal sibling of these
telemetry signals in the same Aspire defaults), and COST.md (the FinOps companion that records
span-filtering and sampling as cost levers).