[Monitor OpenTelemetry Exporter] Persist telemetry on shutdown for short-lived applications - #61818
Conversation
Shutting down a provider now writes pending telemetry to offline storage and uploads it in the background instead of blocking on ingestion. Short-lived applications such as CLI tools exit before a transmission completes, so this telemetry was previously lost. The persist-only scope is opened by the processor rather than the exporter, because BaseExportProcessor exports the remaining batch before it shuts the exporter down. Also fixes three pre-existing storage defects: leased blobs stranded permanently, telemetry dropped at the size cap with no eviction, and the storage handler never being disposed.
|
Azure Pipelines: Successfully started running 1 pipeline(s). 11 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
Pull request overview
This PR improves reliability for short-lived applications using the Azure Monitor OpenTelemetry Exporter by persisting pending telemetry to offline storage during shutdown/dispose and draining it asynchronously, rather than performing a blocking ingestion request on the exit path. It also strengthens offline-storage draining behavior (lease reclamation, eviction at size cap, batching strategy) and adds targeted test coverage.
Changes:
- Persist pending telemetry on provider shutdown/dispose (and optionally ForceFlush) via a persist-only scope opened at the processor/reader level, then trigger a bounded background drain.
- Improve offline storage drain mechanics: eager drain shortly after startup, reclaim expired leases, coalesce blobs oldest-first, and evict oldest blobs when storage is full.
- Add new unit tests covering shutdown persistence, switch behavior, lease reclamation, coalescing/isolation behavior, and eviction.
Reviewed changes
Copilot reviewed 26 out of 26 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Tests/PersistOnShutdownTests.cs | New transmitter-level tests for persist-only scope and storage drain behaviors. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Tests/PersistOnShutdownSwitchCollection.cs | New xUnit collection to serialize tests that mutate AppContext switches. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Tests/PersistOnShutdownProviderTests.cs | New end-to-end provider pipeline tests validating shutdown/dispose persistence behavior and switches. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Tests/OfflineStorageTests.cs | Disable eager drain during these tests to avoid storage-content races. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Tests/CommonTestFramework/MockTransmitter.cs | Update test mock to match new ITransmitter surface (persist-only scope + drain). |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/OpenTelemetryBuilderExtensions.cs | Swap metric reader registration to a custom reader that can persist-on-shutdown. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/TransmitFromStorageHandler.cs | Major rework of drain logic: eager drain, lease reclamation, batching/coalescing, and disposal fixes. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/PersistOnShutdownHelper.cs | New helper to run shutdown/flush in persist-only mode then trigger drain. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/PersistOnShutdownConfig.cs | New configuration + AppContext switches and drain/fallback budgets. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/PersistentStorage/PersistentStorageExtensions.cs | Add save-with-eviction behavior when offline storage hits its size cap. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/LogFilteringProcessor.cs | Route log filtering processor through the new persist-capable batch processor base. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/ITransmitter.cs | Extend transmitter contract to support persist-only scope and bounded drain trigger. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/HttpPipelineHelper.cs | Use save-with-eviction for retry persistence and expose retriable-status helper to internals. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/Diagnostics/AzureMonitorExporterEventSource.cs | Add events for persist-on-shutdown failures and coalesced-batch rejection isolation. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/CustomerSdkStats/DropCodeExtensions.cs | Map new shutdown-persisted drop code to spec bucket. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/CustomerSdkStats/DropCode.cs | Add new drop code for shutdown persistence. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/AzureMonitorTransmitter.cs | Implement persist-only mode, drain triggering/wait budgeting, retention change, and dispose drain handling. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/AzureMonitorPeriodicExportingMetricReader.cs | New metric reader overriding shutdown to persist then drain. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/AzureMonitorBatchLogRecordExportProcessor.cs | New batch log processor overriding shutdown/flush to persist then drain (switch-controlled). |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/AzureMonitorBatchActivityExportProcessor.cs | New batch trace processor overriding shutdown/flush to persist then drain (switch-controlled). |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/ExporterRegistrationHostedService.cs | Register the new persist-capable processors/reader in hosted-service wiring. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/AzureMonitorTraceExporter.cs | Ensure exporter shutdown triggers a drain when user supplies a custom processor. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/AzureMonitorMetricExporter.cs | Ensure exporter shutdown triggers a drain when user supplies a custom reader. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/AzureMonitorLogExporter.cs | Ensure exporter shutdown triggers a drain when user supplies a custom processor. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/AzureMonitorExporterExtensions.cs | Update extension wiring to use new processors/reader. |
| sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/CHANGELOG.md | Document new shutdown persistence behavior, switches, and offline-storage fixes/retention change. |
Move SaveTelemetryWithEviction out of the vendored PersistentStorage folder into a new Internals.ShutdownPersistence namespace, alongside PersistOnShutdownConfig and PersistOnShutdownHelper. Revert offline storage retention to the package default of two days. Ingestion rejects telemetry whose timestamp is older than 48 hours, so a longer retention would only hold payloads guaranteed to be refused, consuming the size cap and wasting drain requests.
…al success Evict only when the storage directory is genuinely at capacity. A write can also fail for permission, disk, or locking reasons, and evicting then destroyed the backlog without saving anything. Eviction is applied only where the directory and cap are known; the retry paths keep the plain save. Remove the exporter OnShutdown overrides. They fired a second DrainStorage nested inside the first, clobbering the tracked in-flight drain so the transmitter could be disposed while the real drain was running, and they added OnShutdown to the public API listing. A 206 response now only discards the coalesced batch once the retryable subset has been re-persisted or confirmed absent, so an unreadable body no longer takes the whole batch with it. Stop deleting blobs when a read fails transiently, and move standard metrics onto the persisting metric reader so its shutdown no longer blocks on ingestion.
…rtial success Reference count the transmitter cached by TransmitterFactory. Every exporter using a connection string shares one instance, so disposing the first of them stopped offline storage draining for the rest. Serialize drain tracking. Each signal shuts down separately against the shared transmitter, and a later shutdown could replace a running drain with a completed no-op, letting disposal proceed underneath the real one. A shutdown that finds a drain already running now waits on it within the remaining budget. Evict one blob at a time and retry the save after each, so a full storage directory discards no more telemetry than necessary. Add tests for the coalesced partial success paths: a readable 206 re-persists only the retryable subset, an unreadable one keeps the batch.
StandardMetricsExtractionProcessor creates its exporter eagerly but only hands it to the lazily built meter provider, so a process that records no spans, or one with both metric options disabled, never disposed it. With the transmitter now reference counted that retained reference kept the shared transmitter alive, leaving its storage timers running past provider disposal. Disposal is idempotent, so releasing it unconditionally is safe when the meter provider already owned it.
Cap a single drain transmission with its own budget. It previously ran with CancellationToken.None, so a hung endpoint could hold the drain for the pipeline's 100 second network timeout, keeping the in-progress flag set and blocking later passes. Locate the lease separator within the file name rather than the full path, so a storage directory containing '@' cannot skew the reclaim target. Assert the lease period keeps a wide margin over a single request, which is what stops another process reclaiming a blob mid-upload.
Noah Gilson (nagilson)
left a comment
There was a problem hiding this comment.
Thank you for engaging with us on #60838. I'm grateful for the time you put into these changes. I left a follow-up from our email thread.
…singData # Conflicts: # sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/CHANGELOG.md # sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/TransmitFromStorageHandler.cs
xiang17
left a comment
There was a problem hiding this comment.
No objection for the code. Comments are doc feedback or not blocking for this PR.
TransmitterFactory kept returning an instance after its final reference was released. Reference counting made this worse: the torn-down instance now has no storage handler or timers, so a provider recreated in the same process lost periodic and eager draining entirely. Before this PR the drain timer outlived disposal, which masked the problem. Note in the changelog that PersistOnForceFlush applies to traces and logs only, since a metric reader cannot distinguish a caller's flush from its periodic collection.
The Statsbeat and customer SDK stats meter providers export once more as they are disposed, which is on the process exit path, and their exporters inherited the pipeline default network timeout of 100 seconds. An unreachable endpoint could therefore stall exit for far longer than the persist-on-shutdown work saves, on exactly the runs where a drain had recorded network statistics. Both now use a 5 second network timeout. Losing internal telemetry is acceptable; stalling exit is not.
4e15ebf
into
Azure:main
Problem
The exporter is network-first, so short-lived applications lose telemetry. A 2-second
dotnet buildnever reaches a batch interval (5s for traces/logs, 60s for metrics), so its only export happens at shutdown ΓÇö and that export is a blocking POST.MaxRetriesis 0 butNetworkTimeoutdefaults to 100 seconds, so exit either pays a full ingestion round trip or stalls. If the process exits first, the batch is gone; nothing durable was written.Telemetry that did reach storage was never uploaded either: the drain timer's first tick is at t+120s.
Change
Shutdown/Disposenow write pending telemetry to offline storage and drain it in the background instead of transmitting inline. A blob is deleted only after HTTP 200, so an abrupt exit cannot lose data ΓÇö the next run drains the backlog.The persist-only scope is opened by the processor, not the exporter, because
BaseExportProcessor.OnShutdownexports the remaining batch before it shuts the exporter down. Same forBaseExportingMetricReader.ForceFlushis unchanged by default ΓÇö callers that flush per invocation expect delivery, not durability. TwoAppContextswitches:PersistOnForceFlushopts in,DisablePersistOnShutdownreverts.PersistOnForceFlushapplies to traces and logs only — a metric reader cannot distinguish a caller's flush from its periodic collection, so metricForceFlushalways transmits.Two independent budgets. The drain budget may be zero (data is already on disk); the fallback transmission used when storage is unavailable gets its own non-zero budget, since there the request is the durability.
Usage
There is nothing new to call and nothing to configure. Existing code gets the behavior as long as the provider is shut down or disposed, which is what makes the telemetry get written to disk.
Console app / CLI:
Host-based app ΓÇö no change;
IHostdisposes the providers:Opting
ForceFlushinto the same behavior, or reverting shutdown to the previous blocking transmission ΓÇö set before building the provider:Two caveats worth knowing:
DisableOfflineStorage = true, or on a read-only filesystem, there is nowhere to persist and shutdown falls back to a transmission ΓÇö now capped at 3 seconds instead of inheriting the 100 second network timeout.Also fixed (pre-existing)
TryLeaserenames to*.lock, which matches neither the provider's"*.blob"enumeration nor its retention sweep. Only the 120s maintenance tick reclaims it ΓÇö which a short-lived process never reaches, so a run that died mid-upload stranded that telemetry forever. Expired leases are now reclaimed on drain.TransmitFromStorageHandlerwas never disposed (leaked timer).Notes
api/*.csis untouched, verified by regenerating witheng/scripts/Export-API.ps1. The overrides areprotectedon internal processor and reader types; putting them on the exporters themselves would be a surface change.DisableOfflineStorage = true, so it would land on the fallback path and add exit latency for internal telemetry whose loss is acceptable.Testing
866 tests pass. New coverage in
PersistOnShutdownTests(transmitter/drain) andPersistOnShutdownProviderTests(realTracerProvider): shutdown and dispose persist rather than transmit, both switches, lease reclamation incl. the unexpired case, coalescing, poison isolation, 206 partial success in both directions, capacity-gated eviction, shared transmitter lifetime, and a 30-second hanging endpoint no longer stalling exit (~2s).Not yet covered, tracked for follow-up: exit/startup latency benchmarks, and concurrency tests for parallel MSBuild nodes sharing one storage directory.