Azure Monitor OpenTelemetry logs can disappear before an ILogger record reaches the exporter, even while traces and metrics continue to arrive. The important clue is not a failed flush or a broken connection string. It is an ambient Activity with a valid span ID whose trace flags say that the trace was not recorded.
Trace-based log filtering is deliberate: it keeps logs aligned with sampled traces and reduces ingestion volume. The risky response is to disable it globally as soon as someone reports missing logs. This guide gives you a local three-path test, shows what the result proves, and turns the configuration change into a measured production decision.
Table of Contents
Why a healthy exporter can still drop a log
Microsoft’s current guidance defines three relevant cases. A log with no trace context is not affected. A log with a valid SpanId and the Recorded trace flag can be exported. A log with a valid SpanId but without Recorded is filtered when trace-based log sampling is enabled.
| Log context | Trace-based filter result | Diagnostic meaning |
|---|---|---|
| No valid span ID | Export | Startup and other spanless logs are outside this filter |
Valid span ID, Recorded set | Export | The log follows a sampled trace |
Valid span ID, Recorded not set | Drop | The log belongs to a trace that sampling chose not to record |
The current exporter source implements that rule before the wrapped log processor receives the record. This explains a confusing incident pattern: the logging call succeeds, the pipeline flush succeeds, and unrelated telemetry continues to arrive, but a correlated log never reaches batching or transport.
This is not automatically a defect in Azure ingestion. It may be the configured consistency policy working as designed. It can still be operationally surprising when a library, function host, or custom component creates an ambient activity that is valid but not recorded.
Inspect Azure Monitor OpenTelemetry logs and the ambient Activity
Add a short-lived diagnostic immediately before one affected logging call. Do not put raw trace identifiers or sensitive request data into ordinary application logs. Record only the state needed to classify the path:
var activity = Activity.Current;
logger.LogInformation(
"OTel log-filter probe: activity={HasActivity}, span={HasSpan}, " +
"recorded={Recorded}, allData={AllDataRequested}",
activity is not null,
activity?.SpanId != default,
activity?.Recorded ?? false,
activity?.IsAllDataRequested ?? false);
logger.LogInformation(
"Checkout authorization denied {@Probe}",
new { Marker = "dnc-filter-probe-v1" });
Activity.Recorded is the direct high-level check for the sampled bit. IsAllDataRequested is useful context for instrumentation, but it is not the condition used by the exporter filter. If the diagnostic line itself is under the same unrecorded activity, send the four booleans to a separate temporary sink such as the console or a test exporter; otherwise the evidence can be filtered with the log you are investigating.
Also check who created the activity and which sampler made the decision. An inherited parent decision, an AlwaysOffSampler, or a rate/ratio sampler can all produce an unrecorded activity. Fixing an unintended trace-sampling policy is usually better than retaining every log globally.
Run a local three-path proof
The smallest reliable proof should bypass Azure entirely. It needs one in-memory exporter and the package’s filtering processor. The following project was run with .NET SDK 10.0.401, Azure.Monitor.OpenTelemetry.Exporter 1.8.3, and OpenTelemetry 1.15.3:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.8.3" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.0" />
<PackageReference Include="OpenTelemetry" Version="1.15.3" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
</ItemGroup>
</Project>
The proof uses reflection only to isolate an internal processor for testing. Do not copy that reflection into production code. The public configuration API appears later.
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Reflection;
using Azure.Monitor.OpenTelemetry.Exporter;
using Microsoft.Extensions.Logging;
using OpenTelemetry;
using OpenTelemetry.Logs;
var exporter = new CountingExporter();
var type = typeof(AzureMonitorExporterOptions).Assembly.GetType(
"Azure.Monitor.OpenTelemetry.Exporter.Internals.LogFilteringProcessor",
throwOnError: true)!;
var processor = (BaseProcessor<LogRecord>)Activator.CreateInstance(
type,
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
binder: null,
args: [exporter],
culture: null)!;
using var factory = LoggerFactory.Create(builder =>
builder.AddOpenTelemetry(options => options.AddProcessor(processor)));
var logger = factory.CreateLogger("FilterProof");
logger.LogInformation("spanless");
processor.ForceFlush(5000);
var afterSpanless = exporter.Messages.Count;
using (var activity = new Activity("unrecorded"))
{
activity.SetParentId(
ActivityTraceId.CreateRandom(),
ActivitySpanId.CreateRandom(),
ActivityTraceFlags.None);
activity.Start();
logger.LogInformation("unrecorded");
}
processor.ForceFlush(5000);
var afterUnrecorded = exporter.Messages.Count;
using (var activity = new Activity("recorded"))
{
activity.SetParentId(
ActivityTraceId.CreateRandom(),
ActivitySpanId.CreateRandom(),
ActivityTraceFlags.Recorded);
activity.Start();
logger.LogInformation("recorded");
}
processor.ForceFlush(5000);
var afterRecorded = exporter.Messages.Count;
Console.WriteLine($"{afterSpanless}/{afterUnrecorded}/{afterRecorded}");
if (afterSpanless != 1 || afterUnrecorded != 1 || afterRecorded != 2)
Environment.ExitCode = 1;
sealed class CountingExporter : BaseExporter<LogRecord>
{
public ConcurrentQueue<string> Messages { get; } = new();
public override ExportResult Export(in Batch<LogRecord> batch)
{
foreach (var record in batch)
Messages.Enqueue(record.FormattedMessage ?? record.Body?.ToString() ?? "");
return ExportResult.Success;
}
}
Run dotnet restore and dotnet run -c Release. The expected counts are 1/1/2: the spanless record increases the count, the unrecorded activity does not, and the recorded activity adds the second exported record. The result was reproduced three times against exporter 1.8.1 and three times against 1.8.3.
If your result differs, stop and inspect the exact package version and pipeline construction. Internal implementation details can change, which is another reason to keep this harness in diagnostics rather than production.
Decide whether to change the filter
When Azure Monitor OpenTelemetry logs are missing only under an unrecorded activity, use the proof to choose a remedy rather than merely confirming the symptom.
| Finding | Preferred action | Why |
|---|---|---|
| The activity should have been sampled | Fix the sampler, parent propagation, or activity creation | Preserves trace-log correlation and the intended sampling model |
| The trace may remain unsampled, but selected audit/error logs must survive | Separate those records into an explicitly governed sink or pipeline | Avoids retaining every correlated informational log |
| All correlated logs are required and the extra volume is acceptable | Disable trace-based log filtering through the public option | Retains logs independently of the trace decision, with an explicit cost tradeoff |
| Only startup or spanless logs are missing | Investigate providers, filters, shutdown, transport, and ingestion | Trace-based filtering does not affect records without trace context |
For the standalone Azure Monitor exporter package, the public setting is:
using Azure.Monitor.OpenTelemetry.Exporter;
builder.Services
.AddOpenTelemetry()
.UseAzureMonitorExporter(options =>
{
options.EnableTraceBasedLogsSampler = false;
});
If your application uses the newer Microsoft OpenTelemetry Distro rather than Azure.Monitor.OpenTelemetry.Exporter directly, follow the current distro configuration page for that package and version. Do not initialize both distros or paste an option into an API surface that does not own it.
Disabling the filter can increase ingestion because logs attached to unsampled traces are retained again. It can also make trace and log views asymmetric: the log exists while the trace it refers to does not. Neither effect is inherently wrong, but both belong in the decision record.
Roll out and verify safely
- Capture a baseline. For a fixed 30-minute workload, record exported log count, trace count, ingestion volume, and the number of expected marker events.
- Canary one instance. Change only the trace-based log setting. Keep logging levels, sampler, exporter version, and traffic split constant.
- Emit controlled markers. Produce one spanless, one unrecorded-activity, and one recorded-activity marker with a non-sensitive property such as
dnc.filter_probe=v1. - Confirm locally and in Azure. The local console or test exporter proves that the application emitted the records. Azure Monitor confirms which records crossed the real export and ingestion path.
- Measure cost-facing volume. Compare retained log count and ingested bytes over equivalent traffic. Extrapolate only after accounting for daily and weekly load patterns.
- Promote or revert. Keep the change only when the recovered evidence is worth the measured volume and the team accepts logs without matching sampled traces.
In an Application Insights workspace, a focused query can verify the marker without treating absence as automatic proof of this filter:
AppTraces
| where TimeGenerated > ago(30m)
| where tostring(Properties["dnc.filter_probe"]) == "v1"
| project TimeGenerated, Message, SeverityLevel, OperationId, Properties
| order by TimeGenerated desc
If the expected record is absent, correlate the result with the local sink, activity flags, deployment version, resource connection, time range, ingestion latency, logger category filters, and exporter diagnostics. A backend query alone cannot identify where the record disappeared.
Keep a simple rollback: restore EnableTraceBasedLogsSampler to its previous value and redeploy the canary. If volume rises faster than expected or downstream alerts become noisy, revert first and refine the sampling or routing design separately.
Know what the proof does not show
- The local harness proves processor behavior in exporter 1.8.1 and 1.8.3. It does not prove that Azure ingestion lost a record.
- The reflection target is internal and may change. The production configuration must use the public option.
- A successful flush means the pipeline processed what it retained; it does not mean every original logging call reached the exporter.
- Trace-based log filtering is only one loss point. Logger category rules, severity filters, shutdown timing, offline storage, transport failures, quotas, and query windows still matter.
- Keeping every log may be correct for an audit boundary, but it is not free. Redact sensitive data and measure volume before broad rollout.
For wider observability design, the OpenTelemetry cardinality budget guide for ASP.NET Core applies the same principle: preserve diagnostic value with an explicit cost boundary instead of removing controls blindly.
References
- Configure OpenTelemetry in Application Insights
- AzureMonitorOptions.EnableTraceBasedLogsSampler API reference
- Azure SDK for .NET issue 61704: trace-based logs sampling drops logs in Azure Functions
- Azure Monitor OpenTelemetry exporter LogFilteringProcessor source
- Activity.Recorded API reference
Found this useful? Support more practical developer content.