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.

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 contextTrace-based filter resultDiagnostic meaning
No valid span IDExportStartup and other spanless logs are outside this filter
Valid span ID, Recorded setExportThe log follows a sampled trace
Valid span ID, Recorded not setDropThe 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.

FindingPreferred actionWhy
The activity should have been sampledFix the sampler, parent propagation, or activity creationPreserves trace-log correlation and the intended sampling model
The trace may remain unsampled, but selected audit/error logs must surviveSeparate those records into an explicitly governed sink or pipelineAvoids retaining every correlated informational log
All correlated logs are required and the extra volume is acceptableDisable trace-based log filtering through the public optionRetains logs independently of the trace decision, with an explicit cost tradeoff
Only startup or spanless logs are missingInvestigate providers, filters, shutdown, transport, and ingestionTrace-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

  1. Capture a baseline. For a fixed 30-minute workload, record exported log count, trace count, ingestion volume, and the number of expected marker events.
  2. Canary one instance. Change only the trace-based log setting. Keep logging levels, sampler, exporter version, and traffic split constant.
  3. 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.
  4. 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.
  5. Measure cost-facing volume. Compare retained log count and ingested bytes over equivalent traffic. Extrapolate only after accounting for daily and weekly load patterns.
  6. 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

Found this useful? Support more practical developer content.

Author

Practical .NET, Angular, Azure, Blazor, and AI engineering for real-world development.

Write A Comment

Ads Blocker Image Powered by Code Help Pro

Ads Blocker Detected!!!

We have detected that you are using extensions to block ads. Please support us by disabling these ads blocker.

Powered By
100% Free SEO Tools - Tool Kits PRO