﻿<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
  <channel xmlns:media="http://search.yahoo.com/mrss/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
    <title>Dev Leader</title>
    <link>https://www.devleader.ca/</link>
    <description />
    <atom:link href="https://www.devleader.ca/feed" rel="self" type="application/rss+xml" />
    <item>
      <guid isPermaLink="false">a500588e-8e21-4410-83da-3f30c4c2ce79</guid>
      <link>https://www.devleader.ca/2026/08/11/nlog-performance-asyncwrapper-buffering-and-highthroughput-logging-in-net</link>
      <category>nlog performance</category>
      <category>nlog asyncwrapper</category>
      <category>nlog buffering</category>
      <category>high throughput logging</category>
      <category>nlog async dotnet</category>
      <title>NLog Performance: AsyncWrapper, Buffering, and High-Throughput Logging in .NET</title>
      <pubDate>Tue, 11 Aug 2026 21:00:00 Z</pubDate>
      <description><![CDATA[<h1 id="nlog-performance-asyncwrapper-buffering-and-high-throughput-logging-in.net">NLog Performance: AsyncWrapper, Buffering, and High-Throughput Logging in .NET</h1>
<p><strong>NLog performance</strong> becomes critical the moment your application processes more than a few hundred requests per second. A synchronous file write on every <code>_logger.LogInformation(...)</code> call adds latency to every request -- and under load, that latency compounds across your entire request pipeline. NLog ships with two built-in wrappers designed for this: <code>AsyncWrapper</code> for fire-and-forget logging on a background thread, and <code>BufferingWrapper</code> for batching log events before flushing to a target.</p>
<p>This guide covers every aspect of optimizing NLog for high-throughput .NET applications. You'll learn how to configure <code>AsyncWrapper</code> with the right queue size and overflow strategy, when to use <code>BufferingWrapper</code> vs. async, how to benchmark your logging configuration with BenchmarkDotNet, and what configuration choices have the biggest impact on throughput. A production-grade configuration template ties everything together at the end.</p>
<p>This article assumes you already have NLog wired into your ASP.NET Core application and are familiar with targets and configuration basics. If you haven't done that yet, the setup process involves adding the <code>NLog.Web.AspNetCore</code> NuGet package and configuring it in <code>Program.cs</code> -- a process covered in the NLog getting-started material before diving into performance tuning.</p>
<h2 id="why-synchronous-logging-hurts-performance">Why Synchronous Logging Hurts Performance</h2>
<p>By default, NLog writes log events synchronously on the calling thread. This means every <code>_logger.LogWarning(...)</code> call blocks until the write completes -- including disk I/O for file targets and network I/O for database or remote targets.</p>
<p>For a file target, a single synchronous write takes roughly 0.1–1 ms depending on disk speed and OS buffering. At 1,000 requests/second with 5 log events per request, that's 5,000 synchronous writes/second -- each potentially competing for the same file handle. Even with OS-level write caching, contention degrades throughput under load.</p>
<p>The impact is worse for remote targets:</p>
<ul>
<li><strong>Database target</strong>: Each log event is an INSERT statement. At 1,000 events/second, that's 1,000 INSERT calls/second -- easily saturating a shared SQL Server instance.</li>
<li><strong>Seq/Elasticsearch targets</strong>: Each event is an HTTP POST. Latency is 5–50 ms per call, making synchronous writes completely impractical under load.</li>
</ul>
<p>NLog's solution is target wrappers that decouple the calling thread from the I/O operation.</p>
<h2 id="asyncwrapper-the-primary-performance-tool">AsyncWrapper: The Primary Performance Tool</h2>
<p><code>AsyncWrapper</code> wraps any NLog target and moves all I/O to a dedicated background thread. The calling thread enqueues the log event in an in-memory queue and returns immediately -- the background thread drains the queue and performs the actual write.</p>
<h3 id="xml-configuration">XML Configuration</h3>
<p>The following configuration wraps a <code>File</code> target inside <code>AsyncWrapper</code>. Notice that the <code>File</code> target is the inner target -- <code>AsyncWrapper</code> sits in front of it and manages the background queue. All configuration options on the file target remain fully functional; <code>AsyncWrapper</code> only changes when the write happens, not how.</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;targets&gt;
  &lt;target xsi:type=&quot;AsyncWrapper&quot;
          name=&quot;asyncFile&quot;
          queueLimit=&quot;10000&quot;
          overflowAction=&quot;Discard&quot;
          batchSize=&quot;200&quot;
          timeToSleepBetweenBatches=&quot;0&quot;&gt;
    &lt;target xsi:type=&quot;File&quot;
            name=&quot;innerFile&quot;
            fileName=&quot;${basedir}/logs/${shortdate}.log&quot;
            layout=&quot;${longdate}|${level:uppercase=true}|${logger:shortName=true}|${message}${exception:format=tostring}&quot;
            keepFileOpen=&quot;true&quot;
            concurrentWrites=&quot;false&quot; /&gt;  &lt;!-- concurrentWrites removed in NLog 6; omit on NLog 6+ --&gt;
  &lt;/target&gt;
&lt;/targets&gt;
</code></pre>
</div><h3 id="json-configuration-appsettings.json">JSON Configuration (appsettings.json)</h3>
<p>The same <code>AsyncWrapper</code> setup expressed in <code>appsettings.json</code> works identically at runtime. The JSON format is preferred when your DevOps pipeline injects connection strings or log levels via environment variables -- ASP.NET Core's configuration system merges <code>appsettings.Production.json</code> and environment variables automatically without any code changes.</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-json">{
  &quot;NLog&quot;: {
    &quot;targets&quot;: {
      &quot;asyncFile&quot;: {
        &quot;type&quot;: &quot;AsyncWrapper&quot;,
        &quot;queueLimit&quot;: 10000,
        &quot;overflowAction&quot;: &quot;Discard&quot;,
        &quot;batchSize&quot;: 200,
        &quot;timeToSleepBetweenBatches&quot;: 0,
        &quot;target&quot;: {
          &quot;type&quot;: &quot;File&quot;,
          &quot;name&quot;: &quot;innerFile&quot;,
          &quot;fileName&quot;: &quot;${basedir}/logs/${shortdate}.log&quot;,
          &quot;keepFileOpen&quot;: true
          // &quot;concurrentWrites&quot;: false  -- removed in NLog 6; omit on NLog 6+
        }
      }
    }
  }
}
</code></pre>
</div><h3 id="key-asyncwrapper-parameters">Key AsyncWrapper Parameters</h3>
<p>The default values for <code>AsyncWrapper</code> are conservative, chosen to be safe for single-server apps rather than optimal for high-throughput services. For production applications under serious load, each of these parameters should be reviewed and tuned against your specific traffic profile and memory constraints.</p>
<table class="table">
<thead>
<tr>
<th>Parameter</th>
<th>Default</th>
<th>Recommended (high load)</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>queueLimit</code></td>
<td>10000</td>
<td>50000–100000</td>
<td>Max events in memory before overflow action fires</td>
</tr>
<tr>
<td><code>overflowAction</code></td>
<td><code>Discard</code></td>
<td><code>Discard</code> or <code>Block</code></td>
<td><code>Discard</code> drops events when queue is full; <code>Block</code> applies back-pressure</td>
</tr>
<tr>
<td><code>batchSize</code></td>
<td>200</td>
<td>500–1000</td>
<td>Events written per background thread iteration</td>
</tr>
<tr>
<td><code>timeToSleepBetweenBatches</code></td>
<td>50 (ms)</td>
<td>0</td>
<td>Set to 0 for maximum throughput; non-zero reduces CPU</td>
</tr>
</tbody>
</table>
<p><strong><code>overflowAction</code> trade-offs:</strong></p>
<ul>
<li><code>Discard</code> -- drops log events when the queue is full. Caller never blocks. Log events are silently lost during extreme spikes. Use for high-priority services where availability matters more than log completeness.</li>
<li><code>Block</code> -- caller blocks when the queue is full until space is available. No events are lost, but your request handler blocks. Useful when log completeness is a compliance requirement.</li>
<li><code>Grow</code> -- expands the queue without bound. Avoid in production -- unbounded memory growth during a traffic spike will cause out-of-memory errors.</li>
</ul>
<h3 id="asynctrue-shorthand-in-json-config"><code>async=&quot;true&quot;</code> Shorthand in JSON Config</h3>
<p>In <code>appsettings.json</code>, adding <code>async: true</code> at the top of the targets section automatically wraps all targets in <code>AsyncWrapper</code> with default parameters. This is the zero-friction option for applications that don't need fine-grained control:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-json">{
  &quot;NLog&quot;: {
    &quot;targets&quot;: {
      &quot;async&quot;: true,
      &quot;file&quot;: {
        &quot;type&quot;: &quot;File&quot;,
        &quot;fileName&quot;: &quot;${basedir}/logs/${shortdate}.log&quot;
      }
    }
  }
}
</code></pre>
</div>
<p>This is equivalent to wrapping each target in <code>AsyncWrapper</code> with <code>queueLimit=10000</code> and <code>overflowAction=Discard</code>. For tuned configurations, use explicit <code>AsyncWrapper</code> setup instead.</p>
<h2 id="bufferingwrapper-batch-writes-for-database-and-remote-targets">BufferingWrapper: Batch Writes for Database and Remote Targets</h2>
<p><code>BufferingWrapper</code> accumulates log events in memory and flushes them in batches when either the buffer is full or a flush interval expires. Unlike <code>AsyncWrapper</code>, it is synchronous by default -- the background thread sends a batch, then waits for the flush to complete before draining more events.</p>
<p>Use <code>BufferingWrapper</code> when your target benefits from batched I/O -- database targets (one INSERT per batch vs. one per event), and HTTP-based targets (one HTTP call per batch vs. per event).</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;targets&gt;
  &lt;target xsi:type=&quot;BufferingWrapper&quot;
          name=&quot;bufferedDb&quot;
          bufferSize=&quot;100&quot;
          flushTimeout=&quot;5000&quot;
          slidingTimeout=&quot;false&quot;&gt;
    &lt;target xsi:type=&quot;Database&quot;
            name=&quot;innerDb&quot;
            dbProvider=&quot;MySql.Data.MySqlClient&quot;
            connectionString=&quot;${environment:DB_CONNECTION_STRING}&quot;
            commandText=&quot;INSERT INTO Logs (Timestamp, Level, Logger, Message, Exception)
                         VALUES (@time, @level, @logger, @msg, @exc)&quot;&gt;
      &lt;parameter name=&quot;@time&quot; layout=&quot;${date:universalTime=true:format=o}&quot; /&gt;
      &lt;parameter name=&quot;@level&quot; layout=&quot;${level:uppercase=true}&quot; /&gt;
      &lt;parameter name=&quot;@logger&quot; layout=&quot;${logger:shortName=true}&quot; /&gt;
      &lt;parameter name=&quot;@msg&quot; layout=&quot;${message}&quot; /&gt;
      &lt;parameter name=&quot;@exc&quot; layout=&quot;${exception:format=tostring}&quot; /&gt;
    &lt;/target&gt;
  &lt;/target&gt;
&lt;/targets&gt;
</code></pre>
</div>
<p>The <code>bufferSize=&quot;100&quot;</code> flushes when 100 events accumulate. The <code>flushTimeout=&quot;5000&quot;</code> flushes any remaining events after 5 seconds even if the buffer is not full. This ensures low-traffic periods still persist log events in a timely manner.</p>
<h3 id="combining-asyncwrapper-and-bufferingwrapper">Combining AsyncWrapper and BufferingWrapper</h3>
<p>For maximum throughput on a database target, wrap <code>BufferingWrapper</code> inside <code>AsyncWrapper</code>:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;targets&gt;
  &lt;target xsi:type=&quot;AsyncWrapper&quot; name=&quot;asyncBufferedDb&quot; queueLimit=&quot;50000&quot; overflowAction=&quot;Discard&quot;&gt;
    &lt;target xsi:type=&quot;BufferingWrapper&quot; bufferSize=&quot;200&quot; flushTimeout=&quot;10000&quot;&gt;
      &lt;target xsi:type=&quot;Database&quot; name=&quot;db&quot; ... /&gt;
    &lt;/target&gt;
  &lt;/target&gt;
&lt;/targets&gt;
</code></pre>
</div>
<p>This configuration:</p>
<ol>
<li><code>AsyncWrapper</code> returns immediately to the caller -- no blocking</li>
<li><code>BufferingWrapper</code> accumulates events in the background thread before flushing</li>
<li><code>Database</code> target receives batches of 200 events per INSERT cycle</li>
</ol>
<p>The result is dramatically fewer database round-trips compared to a synchronous per-event setup. NLog's <code>Database</code> target supports batching natively through this wrapper combination, making it practical for production write-heavy services.</p>
<h2 id="file-target-performance-tuning">File Target Performance Tuning</h2>
<p>Even with <code>AsyncWrapper</code> moving writes off the calling thread, the file target's own configuration shapes how fast the background thread can drain the queue. Two settings in particular have a measurable impact on throughput and are frequently left at their defaults, which are not optimal for high-volume applications.</p>
<h3 id="keepfileopen"><code>keepFileOpen</code></h3>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;!-- NLog 5.x: keepFileOpen + concurrentWrites=&quot;false&quot; for single-process writers --&gt;
&lt;!-- NLog 6:   keepFileOpen only -- ConcurrentWrites was removed --&gt;
&lt;target xsi:type=&quot;File&quot;
        fileName=&quot;${basedir}/logs/app.log&quot;
        keepFileOpen=&quot;true&quot;
        concurrentWrites=&quot;false&quot; /&gt;  &lt;!-- NLog 5.x only; omit on NLog 6+ --&gt;
</code></pre>
</div>
<p>In NLog 5.x, <code>keepFileOpen</code> defaulted to <code>false</code> -- NLog opened and closed the file handle on every write. NLog 6 changed this default to <code>true</code>. Regardless of which version you're on, explicitly setting <code>keepFileOpen=&quot;true&quot;</code> eliminates ambiguity and the repeated open/close system call overhead. This alone can double throughput on file targets.</p>
<p>When <code>keepFileOpen=&quot;true&quot;</code> on NLog 5.x, also set <code>concurrentWrites=&quot;false&quot;</code> unless multiple processes write to the same file -- the concurrent write support adds locking overhead that's unnecessary for single-process writers. <strong>Note: NLog 6 removed <code>ConcurrentWrites</code> from <code>FileTarget</code> entirely.</strong> If you're on NLog 6, omit <code>concurrentWrites</code> from your configuration.</p>
<h3 id="archive-and-rolling-patterns">Archive and Rolling Patterns</h3>
<p>Rolling log files by date or size avoids unbounded file growth. However, the rolling check has a cost -- NLog checks the rolling condition on every write. For high-throughput applications, use <code>archiveAboveSize</code> with a large value (e.g., <code>104857600</code> for 100 MB) rather than hourly rolling, which minimizes the frequency of roll-over events:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;target xsi:type=&quot;File&quot;
        fileName=&quot;${basedir}/logs/app.log&quot;
        archiveFileName=&quot;${basedir}/logs/archive/app.{#}.log&quot;
        archiveAboveSize=&quot;104857600&quot;
        archiveNumbering=&quot;Rolling&quot;
        maxArchiveFiles=&quot;10&quot;
        keepFileOpen=&quot;true&quot;
        concurrentWrites=&quot;false&quot; /&gt;  &lt;!-- NLog 5.x only; omit on NLog 6+ --&gt;
</code></pre>
</div><h2 id="benchmarking-nlog-configuration-with-benchmarkdotnet">Benchmarking NLog Configuration with BenchmarkDotNet</h2>
<p>Before optimizing, measure. Intuitions about which configuration is fastest are often wrong -- <code>AsyncWrapper</code> with a full queue and <code>Block</code> overflow is slower than synchronous logging for small bursts.</p>
<p><a href="https://www.devleader.ca/2024/03/05/how-to-use-benchmarkdotnet-6-simple-performance-boosting-tips-to-get-started">BenchmarkDotNet</a> is the standard tool for .NET microbenchmarks. Here's how to benchmark NLog configurations:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-csharp">using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Microsoft.Extensions.Logging;
using NLog;
using NLog.Extensions.Logging;

public class NLogBenchmark
{
    private Microsoft.Extensions.Logging.ILogger _syncLogger = null!;
    private Microsoft.Extensions.Logging.ILogger _asyncLogger = null!;

    [GlobalSetup]
    public void Setup()
    {
        // Synchronous file target
        var syncConfig = new NLog.Config.LoggingConfiguration();
        var syncTarget = new NLog.Targets.FileTarget(&quot;syncFile&quot;)
        {
            FileName = &quot;${basedir}/logs/sync-bench.log&quot;,
            KeepFileOpen = true,
            ConcurrentWrites = false
        };
        syncConfig.AddRuleForAllLevels(syncTarget);
        var syncFactory = LoggerFactory.Create(b =&gt; b.AddNLog(syncConfig));
        _syncLogger = syncFactory.CreateLogger&lt;NLogBenchmark&gt;();

        // Async file target
        var asyncConfig = new NLog.Config.LoggingConfiguration();
        var asyncTarget = new NLog.Targets.Wrappers.AsyncTargetWrapper(
            new NLog.Targets.FileTarget(&quot;asyncFile&quot;)
            {
                FileName = &quot;${basedir}/logs/async-bench.log&quot;,
                KeepFileOpen = true,
                ConcurrentWrites = false
            })
        {
            QueueLimit = 50000,
            OverflowAction = NLog.Targets.Wrappers.AsyncTargetWrapperOverflowAction.Discard
        };
        asyncConfig.AddRuleForAllLevels(asyncTarget);
        var asyncFactory = LoggerFactory.Create(b =&gt; b.AddNLog(asyncConfig));
        _asyncLogger = asyncFactory.CreateLogger&lt;NLogBenchmark&gt;();
    }

    [Benchmark(Baseline = true)]
    public void SynchronousFileLogging()
    {
        _syncLogger.LogInformation(&quot;Order {OrderId} processed in {ElapsedMs}ms&quot;, 12345, 42);
    }

    [Benchmark]
    public void AsyncWrapperLogging()
    {
        _asyncLogger.LogInformation(&quot;Order {OrderId} processed in {ElapsedMs}ms&quot;, 12345, 42);
    }
}

class Program
{
    static void Main() =&gt; BenchmarkRunner.Run&lt;NLogBenchmark&gt;();
}
</code></pre>
</div>
<p>When running these benchmarks, <code>AsyncWrapper</code> often measures faster per call in microbenchmarks for file targets because the caller only enqueues the event -- the actual I/O happens on the background thread and is not measured in the per-call number. End-to-end throughput depends on I/O speed, queue depth, and background thread scheduling, so always validate under realistic concurrent load. For comprehensive BenchmarkDotNet usage, the guide at <a href="https://www.devleader.ca/2024/03/05/how-to-use-benchmarkdotnet-6-simple-performance-boosting-tips-to-get-started">How to Use BenchmarkDotNet</a> covers the key options.</p>
<h2 id="avoiding-async-void-anti-patterns-in-custom-targets">Avoiding <code>async void</code> Anti-Patterns in Custom Targets</h2>
<p>If you write a custom NLog target that does async I/O (e.g., posting to a webhook), it's tempting to make <code>Write</code> async. Avoid this:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-csharp">// ❌ Dangerous -- async void, exceptions are unobserved
protected override async void Write(LogEventInfo logEvent)
{
    await _httpClient.PostAsync(_webhookUrl, BuildContent(logEvent));
}
</code></pre>
</div>
<p>NLog's <code>Write</code> method is synchronous by design. <code>async void</code> methods fire and forget with no way to track completion or catch exceptions -- a failing HTTP call silently kills the method. The <code>async void</code> pattern is discussed in detail at <a href="https://www.devleader.ca/2024/03/07/async-void-methods-in-c-the-dangers-that-you-need-to-know">Async Void Methods in C#: The Dangers</a>.</p>
<p>Instead, use <code>Task.Run</code> to fire the work onto a thread pool thread, or override <code>WriteAsyncTask</code> which is the correct async extension point in NLog:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-csharp">// ✅ Correct async target extension point
protected override async Task WriteAsyncTask(LogEventInfo logEvent, CancellationToken cancellationToken)
{
    await _httpClient.PostAsync(_webhookUrl, BuildContent(logEvent), cancellationToken);
}
</code></pre>
</div>
<p>When overriding <code>WriteAsyncTask</code>, NLog manages the async scheduling. Wrap the call in <code>AsyncWrapper</code> and NLog coordinates flushing correctly on application shutdown.</p>
<h2 id="aot-and-source-generation-considerations">AOT and Source Generation Considerations</h2>
<p>NLog 6 supports Native AOT with some limitations. <code>AsyncWrapper</code> and <code>BufferingWrapper</code> are both AOT-compatible in NLog 6. However, your app still needs to avoid reflection-heavy config and extension loading without explicit trimming hints -- AOT compatibility covers the wrappers themselves, not arbitrary reflection-based target scanning. If you register NLog targets or extensions viareflection scanning (e.g., using Scrutor or a similar library), prefer explicit registration or a source-generator approach when publishing with trimming enabled. The <a href="https://www.devleader.ca/2026/02/03/automatic-dependency-injection-in-c-the-complete-guide-to-needlr">Automatic Dependency Injection in C# with Needlr</a> guide covers source-generation-based registration that is AOT-safe -- the same principles apply when you need to register custom NLog target types at startup without reflection.</p>
<h2 id="production-performance-template">Production Performance Template</h2>
<p>Here is a complete, ready-to-use high-throughput NLog configuration for an ASP.NET Core application. It combines all the techniques covered above -- async wrappers per target, buffering on the structured JSON target, file tuning, and production-grade rules that silence framework noise while preserving full application output.</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot; ?&gt;
&lt;nlog xmlns=&quot;http://www.nlog-project.org/schemas/NLog.xsd&quot;
      xmlns:xsi=&quot;http://www.w3.org/2001/XMLSchema-instance&quot;
      autoReload=&quot;true&quot;&gt;

  &lt;targets&gt;
    &lt;!-- Async console target (dev-friendly, low overhead) --&gt;
    &lt;target xsi:type=&quot;AsyncWrapper&quot;
            name=&quot;asyncConsole&quot;
            queueLimit=&quot;10000&quot;
            overflowAction=&quot;Discard&quot;&gt;
      &lt;target xsi:type=&quot;ColoredConsole&quot;
              name=&quot;console&quot;
              layout=&quot;${time}|${level:uppercase=true:padding=-5}|${logger:shortName=true}|${message}${exception:format=message}&quot; /&gt;
    &lt;/target&gt;

    &lt;!-- Async rolling file target (high throughput) --&gt;
    &lt;target xsi:type=&quot;AsyncWrapper&quot;
            name=&quot;asyncFile&quot;
            queueLimit=&quot;50000&quot;
            overflowAction=&quot;Discard&quot;
            batchSize=&quot;500&quot;
            timeToSleepBetweenBatches=&quot;0&quot;&gt;
      &lt;target xsi:type=&quot;File&quot;
              name=&quot;file&quot;
              fileName=&quot;${basedir}/logs/${shortdate}.log&quot;
              archiveFileName=&quot;${basedir}/logs/archive/app.{#}.log&quot;
              archiveAboveSize=&quot;104857600&quot;
              archiveNumbering=&quot;Rolling&quot;
              maxArchiveFiles=&quot;10&quot;
              keepFileOpen=&quot;true&quot;
              concurrentWrites=&quot;false&quot;  &lt;!-- NLog 5.x only; omit on NLog 6+ --&gt;
              layout=&quot;${longdate}|${level:uppercase=true}|${mdlc:CorrelationId}|${logger:shortName=true}|${message}${exception:format=tostring}&quot; /&gt;
    &lt;/target&gt;

    &lt;!-- Async buffered structured JSON target (for log aggregation) --&gt;
    &lt;target xsi:type=&quot;AsyncWrapper&quot;
            name=&quot;asyncJson&quot;
            queueLimit=&quot;100000&quot;
            overflowAction=&quot;Discard&quot;&gt;
      &lt;target xsi:type=&quot;BufferingWrapper&quot;
              name=&quot;bufferedJson&quot;
              bufferSize=&quot;500&quot;
              flushTimeout=&quot;5000&quot;&gt;
        &lt;target xsi:type=&quot;File&quot;
                name=&quot;jsonFile&quot;
                fileName=&quot;${basedir}/logs/structured-${shortdate}.json&quot;
                keepFileOpen=&quot;true&quot;
                concurrentWrites=&quot;false&quot;&gt;  &lt;!-- NLog 5.x only; omit on NLog 6+ --&gt;
          &lt;layout xsi:type=&quot;JsonLayout&quot; includeAllProperties=&quot;true&quot; excludeEmptyProperties=&quot;true&quot;&gt;
            &lt;attribute name=&quot;timestamp&quot; layout=&quot;${date:universalTime=true:format=o}&quot; /&gt;
            &lt;attribute name=&quot;level&quot; layout=&quot;${level:uppercase=true}&quot; /&gt;
            &lt;attribute name=&quot;logger&quot; layout=&quot;${logger:shortName=true}&quot; /&gt;
            &lt;attribute name=&quot;correlationId&quot; layout=&quot;${mdlc:CorrelationId}&quot; /&gt;
            &lt;attribute name=&quot;message&quot; layout=&quot;${message}&quot; /&gt;
            &lt;attribute name=&quot;exception&quot; layout=&quot;${exception:format=tostring}&quot; /&gt;
          &lt;/layout&gt;
        &lt;/target&gt;
      &lt;/target&gt;
    &lt;/target&gt;
  &lt;/targets&gt;

  &lt;rules&gt;
    &lt;!-- Suppress EF Core query logging --&gt;
    &lt;logger name=&quot;Microsoft.EntityFrameworkCore.Database.Command&quot; maxlevel=&quot;Info&quot; final=&quot;true&quot; /&gt;
    &lt;!-- Suppress all Microsoft.*/System.* Debug/Info --&gt;
    &lt;logger name=&quot;Microsoft.*&quot; maxlevel=&quot;Info&quot; final=&quot;true&quot; /&gt;
    &lt;logger name=&quot;System.*&quot; maxlevel=&quot;Info&quot; final=&quot;true&quot; /&gt;
    &lt;!-- Framework warnings → file only --&gt;
    &lt;logger name=&quot;Microsoft.*&quot; minlevel=&quot;Warn&quot; writeTo=&quot;asyncFile&quot; final=&quot;true&quot; /&gt;
    &lt;!-- Application → all targets --&gt;
    &lt;logger name=&quot;*&quot; minlevel=&quot;Debug&quot; writeTo=&quot;asyncConsole,asyncFile,asyncJson&quot; /&gt;
  &lt;/rules&gt;
&lt;/nlog&gt;
</code></pre>
</div>
<p>This configuration provides:</p>
<ul>
<li>Non-blocking async for all three targets</li>
<li>500-event batching on the JSON target to reduce flush frequency</li>
<li>Independent queue limits per target (console queue is smaller since console is fast)</li>
<li>Production rules that silence framework noise while preserving all application output</li>
</ul>
<h2 id="frequently-asked-questions">Frequently Asked Questions</h2>
<h3 id="what-is-the-difference-between-asyncwrapper-and-asynctrue-in-nlog">What is the difference between AsyncWrapper and async=&quot;true&quot; in NLog?</h3>
<p><code>async=&quot;true&quot;</code> in <code>appsettings.json</code> automatically wraps every target in <code>AsyncWrapper</code> with default parameters (queueLimit=10000, overflowAction=Discard). An explicit <code>AsyncWrapper</code> lets you tune <code>queueLimit</code>, <code>overflowAction</code>, <code>batchSize</code>, and <code>timeToSleepBetweenBatches</code> per target. Use <code>async=&quot;true&quot;</code> for quick setups and explicit <code>AsyncWrapper</code> when you need fine-grained throughput control for specific targets.</p>
<h3 id="what-should-i-set-queuelimit-to-in-nlog-asyncwrapper">What should I set <code>queueLimit</code> to in NLog AsyncWrapper?</h3>
<p>A reasonable starting point is 10,000–50,000 events. The right value depends on your burst traffic profile and available memory. At 200 bytes per event (approximate average), a 50,000-event queue uses about 10 MB. Monitor your queue utilization under load using NLog's internal logging (<code>internalLogLevel=&quot;Warn&quot;</code>) to see if events are being discarded. If they are, increase <code>queueLimit</code> or reduce event volume at the source.</p>
<h3 id="does-asyncwrapper-prevent-log-events-from-being-lost-on-application-shutdown">Does AsyncWrapper prevent log events from being lost on application shutdown?</h3>
<p><code>AsyncWrapper</code> flushes its queue on shutdown via <code>NLog.LogManager.Shutdown()</code>. If you use <code>NLog.Web.AspNetCore</code>, the flush is called automatically when the host shuts down. If you wire NLog manually, call <code>LogManager.Shutdown()</code> in <code>IHostApplicationLifetime.ApplicationStopped</code> to ensure queued events are written before the process exits.</p>
<h3 id="when-should-i-use-bufferingwrapper-instead-of-asyncwrapper">When should I use BufferingWrapper instead of AsyncWrapper?</h3>
<p>Use <code>BufferingWrapper</code> when your target benefits from batch I/O -- most commonly database targets and HTTP-based targets. A single INSERT with 100 rows is much faster than 100 separate INSERTs. <code>AsyncWrapper</code> makes the caller non-blocking; <code>BufferingWrapper</code> reduces I/O round-trips. Use both together for the best of both: non-blocking caller plus batched writes.</p>
<h3 id="does-keepfileopentrue-break-log-rotation-on-linux">Does <code>keepFileOpen=&quot;true&quot;</code> break log rotation on Linux?</h3>
<p>On Linux, a file with an open handle can still be deleted (the inode remains until all handles are released). Log rotation tools like <code>logrotate</code> use the <code>copytruncate</code> option to work with applications that keep files open. NLog also supports <code>autoFlush=&quot;true&quot;</code> and the <code>ArchiveOldFileOnStartup</code> option. For containerized deployments, write to stdout and let the container runtime handle log collection -- which sidesteps file rotation entirely.</p>
<h3 id="how-do-i-measure-the-actual-throughput-of-my-nlog-configuration">How do I measure the actual throughput of my NLog configuration?</h3>
<p>Use BenchmarkDotNet with realistic message payloads and real targets (not null targets), and measure under concurrent load that matches your production request pattern. Single-threaded benchmarks underestimate contention on the file handle or queue. Test with multiple concurrent <code>Task</code> instances calling the logger to simulate real-world producer/consumer load on the <code>AsyncWrapper</code> queue.</p>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>NLog performance optimization comes down to three principles: move I/O off the calling thread with <code>AsyncWrapper</code>, reduce I/O round-trips with <code>BufferingWrapper</code>, and configure file targets to keep files open. Most applications can achieve sub-microsecond per-call logging latency with these tools properly configured.</p>
<p>The key takeaways:</p>
<ul>
<li><strong>AsyncWrapper is the first and most important optimization</strong> -- it turns every logging call from a blocking I/O operation into an in-memory queue enqueue</li>
<li><strong>Tune <code>queueLimit</code> and <code>overflowAction</code> per target</strong> based on the target's write speed and your tolerance for dropped events</li>
<li><strong>Combine AsyncWrapper + BufferingWrapper for database and HTTP targets</strong> to batch I/O and reduce network round-trips</li>
<li><strong><code>keepFileOpen=&quot;true&quot;</code> is free throughput</strong> for single-process file targets; on NLog 5.x, pair with <code>concurrentWrites=&quot;false&quot;</code> (NLog 6 removed <code>ConcurrentWrites</code> from <code>FileTarget</code>)</li>
<li><strong>Benchmark with BenchmarkDotNet under realistic concurrent load</strong> before and after changes</li>
</ul>
<p>Once you've optimized NLog for throughput, the next architectural question is often whether NLog is the right choice for your project. NLog and Serilog have different strengths -- NLog's rules-based routing is more powerful for complex multi-target scenarios, while Serilog's sink ecosystem is broader and its C# fluent API is more natural for teams that prefer code-first configuration.</p>
]]></description>
      <content:encoded><![CDATA[<h1 id="nlog-performance-asyncwrapper-buffering-and-high-throughput-logging-in.net">NLog Performance: AsyncWrapper, Buffering, and High-Throughput Logging in .NET</h1>
<p><strong>NLog performance</strong> becomes critical the moment your application processes more than a few hundred requests per second. A synchronous file write on every <code>_logger.LogInformation(...)</code> call adds latency to every request -- and under load, that latency compounds across your entire request pipeline. NLog ships with two built-in wrappers designed for this: <code>AsyncWrapper</code> for fire-and-forget logging on a background thread, and <code>BufferingWrapper</code> for batching log events before flushing to a target.</p>
<p>This guide covers every aspect of optimizing NLog for high-throughput .NET applications. You'll learn how to configure <code>AsyncWrapper</code> with the right queue size and overflow strategy, when to use <code>BufferingWrapper</code> vs. async, how to benchmark your logging configuration with BenchmarkDotNet, and what configuration choices have the biggest impact on throughput. A production-grade configuration template ties everything together at the end.</p>
<p>This article assumes you already have NLog wired into your ASP.NET Core application and are familiar with targets and configuration basics. If you haven't done that yet, the setup process involves adding the <code>NLog.Web.AspNetCore</code> NuGet package and configuring it in <code>Program.cs</code> -- a process covered in the NLog getting-started material before diving into performance tuning.</p>
<h2 id="why-synchronous-logging-hurts-performance">Why Synchronous Logging Hurts Performance</h2>
<p>By default, NLog writes log events synchronously on the calling thread. This means every <code>_logger.LogWarning(...)</code> call blocks until the write completes -- including disk I/O for file targets and network I/O for database or remote targets.</p>
<p>For a file target, a single synchronous write takes roughly 0.1–1 ms depending on disk speed and OS buffering. At 1,000 requests/second with 5 log events per request, that's 5,000 synchronous writes/second -- each potentially competing for the same file handle. Even with OS-level write caching, contention degrades throughput under load.</p>
<p>The impact is worse for remote targets:</p>
<ul>
<li><strong>Database target</strong>: Each log event is an INSERT statement. At 1,000 events/second, that's 1,000 INSERT calls/second -- easily saturating a shared SQL Server instance.</li>
<li><strong>Seq/Elasticsearch targets</strong>: Each event is an HTTP POST. Latency is 5–50 ms per call, making synchronous writes completely impractical under load.</li>
</ul>
<p>NLog's solution is target wrappers that decouple the calling thread from the I/O operation.</p>
<h2 id="asyncwrapper-the-primary-performance-tool">AsyncWrapper: The Primary Performance Tool</h2>
<p><code>AsyncWrapper</code> wraps any NLog target and moves all I/O to a dedicated background thread. The calling thread enqueues the log event in an in-memory queue and returns immediately -- the background thread drains the queue and performs the actual write.</p>
<h3 id="xml-configuration">XML Configuration</h3>
<p>The following configuration wraps a <code>File</code> target inside <code>AsyncWrapper</code>. Notice that the <code>File</code> target is the inner target -- <code>AsyncWrapper</code> sits in front of it and manages the background queue. All configuration options on the file target remain fully functional; <code>AsyncWrapper</code> only changes when the write happens, not how.</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;targets&gt;
  &lt;target xsi:type=&quot;AsyncWrapper&quot;
          name=&quot;asyncFile&quot;
          queueLimit=&quot;10000&quot;
          overflowAction=&quot;Discard&quot;
          batchSize=&quot;200&quot;
          timeToSleepBetweenBatches=&quot;0&quot;&gt;
    &lt;target xsi:type=&quot;File&quot;
            name=&quot;innerFile&quot;
            fileName=&quot;${basedir}/logs/${shortdate}.log&quot;
            layout=&quot;${longdate}|${level:uppercase=true}|${logger:shortName=true}|${message}${exception:format=tostring}&quot;
            keepFileOpen=&quot;true&quot;
            concurrentWrites=&quot;false&quot; /&gt;  &lt;!-- concurrentWrites removed in NLog 6; omit on NLog 6+ --&gt;
  &lt;/target&gt;
&lt;/targets&gt;
</code></pre>
</div><h3 id="json-configuration-appsettings.json">JSON Configuration (appsettings.json)</h3>
<p>The same <code>AsyncWrapper</code> setup expressed in <code>appsettings.json</code> works identically at runtime. The JSON format is preferred when your DevOps pipeline injects connection strings or log levels via environment variables -- ASP.NET Core's configuration system merges <code>appsettings.Production.json</code> and environment variables automatically without any code changes.</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-json">{
  &quot;NLog&quot;: {
    &quot;targets&quot;: {
      &quot;asyncFile&quot;: {
        &quot;type&quot;: &quot;AsyncWrapper&quot;,
        &quot;queueLimit&quot;: 10000,
        &quot;overflowAction&quot;: &quot;Discard&quot;,
        &quot;batchSize&quot;: 200,
        &quot;timeToSleepBetweenBatches&quot;: 0,
        &quot;target&quot;: {
          &quot;type&quot;: &quot;File&quot;,
          &quot;name&quot;: &quot;innerFile&quot;,
          &quot;fileName&quot;: &quot;${basedir}/logs/${shortdate}.log&quot;,
          &quot;keepFileOpen&quot;: true
          // &quot;concurrentWrites&quot;: false  -- removed in NLog 6; omit on NLog 6+
        }
      }
    }
  }
}
</code></pre>
</div><h3 id="key-asyncwrapper-parameters">Key AsyncWrapper Parameters</h3>
<p>The default values for <code>AsyncWrapper</code> are conservative, chosen to be safe for single-server apps rather than optimal for high-throughput services. For production applications under serious load, each of these parameters should be reviewed and tuned against your specific traffic profile and memory constraints.</p>
<table class="table">
<thead>
<tr>
<th>Parameter</th>
<th>Default</th>
<th>Recommended (high load)</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>queueLimit</code></td>
<td>10000</td>
<td>50000–100000</td>
<td>Max events in memory before overflow action fires</td>
</tr>
<tr>
<td><code>overflowAction</code></td>
<td><code>Discard</code></td>
<td><code>Discard</code> or <code>Block</code></td>
<td><code>Discard</code> drops events when queue is full; <code>Block</code> applies back-pressure</td>
</tr>
<tr>
<td><code>batchSize</code></td>
<td>200</td>
<td>500–1000</td>
<td>Events written per background thread iteration</td>
</tr>
<tr>
<td><code>timeToSleepBetweenBatches</code></td>
<td>50 (ms)</td>
<td>0</td>
<td>Set to 0 for maximum throughput; non-zero reduces CPU</td>
</tr>
</tbody>
</table>
<p><strong><code>overflowAction</code> trade-offs:</strong></p>
<ul>
<li><code>Discard</code> -- drops log events when the queue is full. Caller never blocks. Log events are silently lost during extreme spikes. Use for high-priority services where availability matters more than log completeness.</li>
<li><code>Block</code> -- caller blocks when the queue is full until space is available. No events are lost, but your request handler blocks. Useful when log completeness is a compliance requirement.</li>
<li><code>Grow</code> -- expands the queue without bound. Avoid in production -- unbounded memory growth during a traffic spike will cause out-of-memory errors.</li>
</ul>
<h3 id="asynctrue-shorthand-in-json-config"><code>async=&quot;true&quot;</code> Shorthand in JSON Config</h3>
<p>In <code>appsettings.json</code>, adding <code>async: true</code> at the top of the targets section automatically wraps all targets in <code>AsyncWrapper</code> with default parameters. This is the zero-friction option for applications that don't need fine-grained control:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-json">{
  &quot;NLog&quot;: {
    &quot;targets&quot;: {
      &quot;async&quot;: true,
      &quot;file&quot;: {
        &quot;type&quot;: &quot;File&quot;,
        &quot;fileName&quot;: &quot;${basedir}/logs/${shortdate}.log&quot;
      }
    }
  }
}
</code></pre>
</div>
<p>This is equivalent to wrapping each target in <code>AsyncWrapper</code> with <code>queueLimit=10000</code> and <code>overflowAction=Discard</code>. For tuned configurations, use explicit <code>AsyncWrapper</code> setup instead.</p>
<h2 id="bufferingwrapper-batch-writes-for-database-and-remote-targets">BufferingWrapper: Batch Writes for Database and Remote Targets</h2>
<p><code>BufferingWrapper</code> accumulates log events in memory and flushes them in batches when either the buffer is full or a flush interval expires. Unlike <code>AsyncWrapper</code>, it is synchronous by default -- the background thread sends a batch, then waits for the flush to complete before draining more events.</p>
<p>Use <code>BufferingWrapper</code> when your target benefits from batched I/O -- database targets (one INSERT per batch vs. one per event), and HTTP-based targets (one HTTP call per batch vs. per event).</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;targets&gt;
  &lt;target xsi:type=&quot;BufferingWrapper&quot;
          name=&quot;bufferedDb&quot;
          bufferSize=&quot;100&quot;
          flushTimeout=&quot;5000&quot;
          slidingTimeout=&quot;false&quot;&gt;
    &lt;target xsi:type=&quot;Database&quot;
            name=&quot;innerDb&quot;
            dbProvider=&quot;MySql.Data.MySqlClient&quot;
            connectionString=&quot;${environment:DB_CONNECTION_STRING}&quot;
            commandText=&quot;INSERT INTO Logs (Timestamp, Level, Logger, Message, Exception)
                         VALUES (@time, @level, @logger, @msg, @exc)&quot;&gt;
      &lt;parameter name=&quot;@time&quot; layout=&quot;${date:universalTime=true:format=o}&quot; /&gt;
      &lt;parameter name=&quot;@level&quot; layout=&quot;${level:uppercase=true}&quot; /&gt;
      &lt;parameter name=&quot;@logger&quot; layout=&quot;${logger:shortName=true}&quot; /&gt;
      &lt;parameter name=&quot;@msg&quot; layout=&quot;${message}&quot; /&gt;
      &lt;parameter name=&quot;@exc&quot; layout=&quot;${exception:format=tostring}&quot; /&gt;
    &lt;/target&gt;
  &lt;/target&gt;
&lt;/targets&gt;
</code></pre>
</div>
<p>The <code>bufferSize=&quot;100&quot;</code> flushes when 100 events accumulate. The <code>flushTimeout=&quot;5000&quot;</code> flushes any remaining events after 5 seconds even if the buffer is not full. This ensures low-traffic periods still persist log events in a timely manner.</p>
<h3 id="combining-asyncwrapper-and-bufferingwrapper">Combining AsyncWrapper and BufferingWrapper</h3>
<p>For maximum throughput on a database target, wrap <code>BufferingWrapper</code> inside <code>AsyncWrapper</code>:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;targets&gt;
  &lt;target xsi:type=&quot;AsyncWrapper&quot; name=&quot;asyncBufferedDb&quot; queueLimit=&quot;50000&quot; overflowAction=&quot;Discard&quot;&gt;
    &lt;target xsi:type=&quot;BufferingWrapper&quot; bufferSize=&quot;200&quot; flushTimeout=&quot;10000&quot;&gt;
      &lt;target xsi:type=&quot;Database&quot; name=&quot;db&quot; ... /&gt;
    &lt;/target&gt;
  &lt;/target&gt;
&lt;/targets&gt;
</code></pre>
</div>
<p>This configuration:</p>
<ol>
<li><code>AsyncWrapper</code> returns immediately to the caller -- no blocking</li>
<li><code>BufferingWrapper</code> accumulates events in the background thread before flushing</li>
<li><code>Database</code> target receives batches of 200 events per INSERT cycle</li>
</ol>
<p>The result is dramatically fewer database round-trips compared to a synchronous per-event setup. NLog's <code>Database</code> target supports batching natively through this wrapper combination, making it practical for production write-heavy services.</p>
<h2 id="file-target-performance-tuning">File Target Performance Tuning</h2>
<p>Even with <code>AsyncWrapper</code> moving writes off the calling thread, the file target's own configuration shapes how fast the background thread can drain the queue. Two settings in particular have a measurable impact on throughput and are frequently left at their defaults, which are not optimal for high-volume applications.</p>
<h3 id="keepfileopen"><code>keepFileOpen</code></h3>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;!-- NLog 5.x: keepFileOpen + concurrentWrites=&quot;false&quot; for single-process writers --&gt;
&lt;!-- NLog 6:   keepFileOpen only -- ConcurrentWrites was removed --&gt;
&lt;target xsi:type=&quot;File&quot;
        fileName=&quot;${basedir}/logs/app.log&quot;
        keepFileOpen=&quot;true&quot;
        concurrentWrites=&quot;false&quot; /&gt;  &lt;!-- NLog 5.x only; omit on NLog 6+ --&gt;
</code></pre>
</div>
<p>In NLog 5.x, <code>keepFileOpen</code> defaulted to <code>false</code> -- NLog opened and closed the file handle on every write. NLog 6 changed this default to <code>true</code>. Regardless of which version you're on, explicitly setting <code>keepFileOpen=&quot;true&quot;</code> eliminates ambiguity and the repeated open/close system call overhead. This alone can double throughput on file targets.</p>
<p>When <code>keepFileOpen=&quot;true&quot;</code> on NLog 5.x, also set <code>concurrentWrites=&quot;false&quot;</code> unless multiple processes write to the same file -- the concurrent write support adds locking overhead that's unnecessary for single-process writers. <strong>Note: NLog 6 removed <code>ConcurrentWrites</code> from <code>FileTarget</code> entirely.</strong> If you're on NLog 6, omit <code>concurrentWrites</code> from your configuration.</p>
<h3 id="archive-and-rolling-patterns">Archive and Rolling Patterns</h3>
<p>Rolling log files by date or size avoids unbounded file growth. However, the rolling check has a cost -- NLog checks the rolling condition on every write. For high-throughput applications, use <code>archiveAboveSize</code> with a large value (e.g., <code>104857600</code> for 100 MB) rather than hourly rolling, which minimizes the frequency of roll-over events:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;target xsi:type=&quot;File&quot;
        fileName=&quot;${basedir}/logs/app.log&quot;
        archiveFileName=&quot;${basedir}/logs/archive/app.{#}.log&quot;
        archiveAboveSize=&quot;104857600&quot;
        archiveNumbering=&quot;Rolling&quot;
        maxArchiveFiles=&quot;10&quot;
        keepFileOpen=&quot;true&quot;
        concurrentWrites=&quot;false&quot; /&gt;  &lt;!-- NLog 5.x only; omit on NLog 6+ --&gt;
</code></pre>
</div><h2 id="benchmarking-nlog-configuration-with-benchmarkdotnet">Benchmarking NLog Configuration with BenchmarkDotNet</h2>
<p>Before optimizing, measure. Intuitions about which configuration is fastest are often wrong -- <code>AsyncWrapper</code> with a full queue and <code>Block</code> overflow is slower than synchronous logging for small bursts.</p>
<p><a href="https://www.devleader.ca/2024/03/05/how-to-use-benchmarkdotnet-6-simple-performance-boosting-tips-to-get-started">BenchmarkDotNet</a> is the standard tool for .NET microbenchmarks. Here's how to benchmark NLog configurations:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-csharp">using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Microsoft.Extensions.Logging;
using NLog;
using NLog.Extensions.Logging;

public class NLogBenchmark
{
    private Microsoft.Extensions.Logging.ILogger _syncLogger = null!;
    private Microsoft.Extensions.Logging.ILogger _asyncLogger = null!;

    [GlobalSetup]
    public void Setup()
    {
        // Synchronous file target
        var syncConfig = new NLog.Config.LoggingConfiguration();
        var syncTarget = new NLog.Targets.FileTarget(&quot;syncFile&quot;)
        {
            FileName = &quot;${basedir}/logs/sync-bench.log&quot;,
            KeepFileOpen = true,
            ConcurrentWrites = false
        };
        syncConfig.AddRuleForAllLevels(syncTarget);
        var syncFactory = LoggerFactory.Create(b =&gt; b.AddNLog(syncConfig));
        _syncLogger = syncFactory.CreateLogger&lt;NLogBenchmark&gt;();

        // Async file target
        var asyncConfig = new NLog.Config.LoggingConfiguration();
        var asyncTarget = new NLog.Targets.Wrappers.AsyncTargetWrapper(
            new NLog.Targets.FileTarget(&quot;asyncFile&quot;)
            {
                FileName = &quot;${basedir}/logs/async-bench.log&quot;,
                KeepFileOpen = true,
                ConcurrentWrites = false
            })
        {
            QueueLimit = 50000,
            OverflowAction = NLog.Targets.Wrappers.AsyncTargetWrapperOverflowAction.Discard
        };
        asyncConfig.AddRuleForAllLevels(asyncTarget);
        var asyncFactory = LoggerFactory.Create(b =&gt; b.AddNLog(asyncConfig));
        _asyncLogger = asyncFactory.CreateLogger&lt;NLogBenchmark&gt;();
    }

    [Benchmark(Baseline = true)]
    public void SynchronousFileLogging()
    {
        _syncLogger.LogInformation(&quot;Order {OrderId} processed in {ElapsedMs}ms&quot;, 12345, 42);
    }

    [Benchmark]
    public void AsyncWrapperLogging()
    {
        _asyncLogger.LogInformation(&quot;Order {OrderId} processed in {ElapsedMs}ms&quot;, 12345, 42);
    }
}

class Program
{
    static void Main() =&gt; BenchmarkRunner.Run&lt;NLogBenchmark&gt;();
}
</code></pre>
</div>
<p>When running these benchmarks, <code>AsyncWrapper</code> often measures faster per call in microbenchmarks for file targets because the caller only enqueues the event -- the actual I/O happens on the background thread and is not measured in the per-call number. End-to-end throughput depends on I/O speed, queue depth, and background thread scheduling, so always validate under realistic concurrent load. For comprehensive BenchmarkDotNet usage, the guide at <a href="https://www.devleader.ca/2024/03/05/how-to-use-benchmarkdotnet-6-simple-performance-boosting-tips-to-get-started">How to Use BenchmarkDotNet</a> covers the key options.</p>
<h2 id="avoiding-async-void-anti-patterns-in-custom-targets">Avoiding <code>async void</code> Anti-Patterns in Custom Targets</h2>
<p>If you write a custom NLog target that does async I/O (e.g., posting to a webhook), it's tempting to make <code>Write</code> async. Avoid this:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-csharp">// ❌ Dangerous -- async void, exceptions are unobserved
protected override async void Write(LogEventInfo logEvent)
{
    await _httpClient.PostAsync(_webhookUrl, BuildContent(logEvent));
}
</code></pre>
</div>
<p>NLog's <code>Write</code> method is synchronous by design. <code>async void</code> methods fire and forget with no way to track completion or catch exceptions -- a failing HTTP call silently kills the method. The <code>async void</code> pattern is discussed in detail at <a href="https://www.devleader.ca/2024/03/07/async-void-methods-in-c-the-dangers-that-you-need-to-know">Async Void Methods in C#: The Dangers</a>.</p>
<p>Instead, use <code>Task.Run</code> to fire the work onto a thread pool thread, or override <code>WriteAsyncTask</code> which is the correct async extension point in NLog:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-csharp">// ✅ Correct async target extension point
protected override async Task WriteAsyncTask(LogEventInfo logEvent, CancellationToken cancellationToken)
{
    await _httpClient.PostAsync(_webhookUrl, BuildContent(logEvent), cancellationToken);
}
</code></pre>
</div>
<p>When overriding <code>WriteAsyncTask</code>, NLog manages the async scheduling. Wrap the call in <code>AsyncWrapper</code> and NLog coordinates flushing correctly on application shutdown.</p>
<h2 id="aot-and-source-generation-considerations">AOT and Source Generation Considerations</h2>
<p>NLog 6 supports Native AOT with some limitations. <code>AsyncWrapper</code> and <code>BufferingWrapper</code> are both AOT-compatible in NLog 6. However, your app still needs to avoid reflection-heavy config and extension loading without explicit trimming hints -- AOT compatibility covers the wrappers themselves, not arbitrary reflection-based target scanning. If you register NLog targets or extensions viareflection scanning (e.g., using Scrutor or a similar library), prefer explicit registration or a source-generator approach when publishing with trimming enabled. The <a href="https://www.devleader.ca/2026/02/03/automatic-dependency-injection-in-c-the-complete-guide-to-needlr">Automatic Dependency Injection in C# with Needlr</a> guide covers source-generation-based registration that is AOT-safe -- the same principles apply when you need to register custom NLog target types at startup without reflection.</p>
<h2 id="production-performance-template">Production Performance Template</h2>
<p>Here is a complete, ready-to-use high-throughput NLog configuration for an ASP.NET Core application. It combines all the techniques covered above -- async wrappers per target, buffering on the structured JSON target, file tuning, and production-grade rules that silence framework noise while preserving full application output.</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot; ?&gt;
&lt;nlog xmlns=&quot;http://www.nlog-project.org/schemas/NLog.xsd&quot;
      xmlns:xsi=&quot;http://www.w3.org/2001/XMLSchema-instance&quot;
      autoReload=&quot;true&quot;&gt;

  &lt;targets&gt;
    &lt;!-- Async console target (dev-friendly, low overhead) --&gt;
    &lt;target xsi:type=&quot;AsyncWrapper&quot;
            name=&quot;asyncConsole&quot;
            queueLimit=&quot;10000&quot;
            overflowAction=&quot;Discard&quot;&gt;
      &lt;target xsi:type=&quot;ColoredConsole&quot;
              name=&quot;console&quot;
              layout=&quot;${time}|${level:uppercase=true:padding=-5}|${logger:shortName=true}|${message}${exception:format=message}&quot; /&gt;
    &lt;/target&gt;

    &lt;!-- Async rolling file target (high throughput) --&gt;
    &lt;target xsi:type=&quot;AsyncWrapper&quot;
            name=&quot;asyncFile&quot;
            queueLimit=&quot;50000&quot;
            overflowAction=&quot;Discard&quot;
            batchSize=&quot;500&quot;
            timeToSleepBetweenBatches=&quot;0&quot;&gt;
      &lt;target xsi:type=&quot;File&quot;
              name=&quot;file&quot;
              fileName=&quot;${basedir}/logs/${shortdate}.log&quot;
              archiveFileName=&quot;${basedir}/logs/archive/app.{#}.log&quot;
              archiveAboveSize=&quot;104857600&quot;
              archiveNumbering=&quot;Rolling&quot;
              maxArchiveFiles=&quot;10&quot;
              keepFileOpen=&quot;true&quot;
              concurrentWrites=&quot;false&quot;  &lt;!-- NLog 5.x only; omit on NLog 6+ --&gt;
              layout=&quot;${longdate}|${level:uppercase=true}|${mdlc:CorrelationId}|${logger:shortName=true}|${message}${exception:format=tostring}&quot; /&gt;
    &lt;/target&gt;

    &lt;!-- Async buffered structured JSON target (for log aggregation) --&gt;
    &lt;target xsi:type=&quot;AsyncWrapper&quot;
            name=&quot;asyncJson&quot;
            queueLimit=&quot;100000&quot;
            overflowAction=&quot;Discard&quot;&gt;
      &lt;target xsi:type=&quot;BufferingWrapper&quot;
              name=&quot;bufferedJson&quot;
              bufferSize=&quot;500&quot;
              flushTimeout=&quot;5000&quot;&gt;
        &lt;target xsi:type=&quot;File&quot;
                name=&quot;jsonFile&quot;
                fileName=&quot;${basedir}/logs/structured-${shortdate}.json&quot;
                keepFileOpen=&quot;true&quot;
                concurrentWrites=&quot;false&quot;&gt;  &lt;!-- NLog 5.x only; omit on NLog 6+ --&gt;
          &lt;layout xsi:type=&quot;JsonLayout&quot; includeAllProperties=&quot;true&quot; excludeEmptyProperties=&quot;true&quot;&gt;
            &lt;attribute name=&quot;timestamp&quot; layout=&quot;${date:universalTime=true:format=o}&quot; /&gt;
            &lt;attribute name=&quot;level&quot; layout=&quot;${level:uppercase=true}&quot; /&gt;
            &lt;attribute name=&quot;logger&quot; layout=&quot;${logger:shortName=true}&quot; /&gt;
            &lt;attribute name=&quot;correlationId&quot; layout=&quot;${mdlc:CorrelationId}&quot; /&gt;
            &lt;attribute name=&quot;message&quot; layout=&quot;${message}&quot; /&gt;
            &lt;attribute name=&quot;exception&quot; layout=&quot;${exception:format=tostring}&quot; /&gt;
          &lt;/layout&gt;
        &lt;/target&gt;
      &lt;/target&gt;
    &lt;/target&gt;
  &lt;/targets&gt;

  &lt;rules&gt;
    &lt;!-- Suppress EF Core query logging --&gt;
    &lt;logger name=&quot;Microsoft.EntityFrameworkCore.Database.Command&quot; maxlevel=&quot;Info&quot; final=&quot;true&quot; /&gt;
    &lt;!-- Suppress all Microsoft.*/System.* Debug/Info --&gt;
    &lt;logger name=&quot;Microsoft.*&quot; maxlevel=&quot;Info&quot; final=&quot;true&quot; /&gt;
    &lt;logger name=&quot;System.*&quot; maxlevel=&quot;Info&quot; final=&quot;true&quot; /&gt;
    &lt;!-- Framework warnings → file only --&gt;
    &lt;logger name=&quot;Microsoft.*&quot; minlevel=&quot;Warn&quot; writeTo=&quot;asyncFile&quot; final=&quot;true&quot; /&gt;
    &lt;!-- Application → all targets --&gt;
    &lt;logger name=&quot;*&quot; minlevel=&quot;Debug&quot; writeTo=&quot;asyncConsole,asyncFile,asyncJson&quot; /&gt;
  &lt;/rules&gt;
&lt;/nlog&gt;
</code></pre>
</div>
<p>This configuration provides:</p>
<ul>
<li>Non-blocking async for all three targets</li>
<li>500-event batching on the JSON target to reduce flush frequency</li>
<li>Independent queue limits per target (console queue is smaller since console is fast)</li>
<li>Production rules that silence framework noise while preserving all application output</li>
</ul>
<h2 id="frequently-asked-questions">Frequently Asked Questions</h2>
<h3 id="what-is-the-difference-between-asyncwrapper-and-asynctrue-in-nlog">What is the difference between AsyncWrapper and async=&quot;true&quot; in NLog?</h3>
<p><code>async=&quot;true&quot;</code> in <code>appsettings.json</code> automatically wraps every target in <code>AsyncWrapper</code> with default parameters (queueLimit=10000, overflowAction=Discard). An explicit <code>AsyncWrapper</code> lets you tune <code>queueLimit</code>, <code>overflowAction</code>, <code>batchSize</code>, and <code>timeToSleepBetweenBatches</code> per target. Use <code>async=&quot;true&quot;</code> for quick setups and explicit <code>AsyncWrapper</code> when you need fine-grained throughput control for specific targets.</p>
<h3 id="what-should-i-set-queuelimit-to-in-nlog-asyncwrapper">What should I set <code>queueLimit</code> to in NLog AsyncWrapper?</h3>
<p>A reasonable starting point is 10,000–50,000 events. The right value depends on your burst traffic profile and available memory. At 200 bytes per event (approximate average), a 50,000-event queue uses about 10 MB. Monitor your queue utilization under load using NLog's internal logging (<code>internalLogLevel=&quot;Warn&quot;</code>) to see if events are being discarded. If they are, increase <code>queueLimit</code> or reduce event volume at the source.</p>
<h3 id="does-asyncwrapper-prevent-log-events-from-being-lost-on-application-shutdown">Does AsyncWrapper prevent log events from being lost on application shutdown?</h3>
<p><code>AsyncWrapper</code> flushes its queue on shutdown via <code>NLog.LogManager.Shutdown()</code>. If you use <code>NLog.Web.AspNetCore</code>, the flush is called automatically when the host shuts down. If you wire NLog manually, call <code>LogManager.Shutdown()</code> in <code>IHostApplicationLifetime.ApplicationStopped</code> to ensure queued events are written before the process exits.</p>
<h3 id="when-should-i-use-bufferingwrapper-instead-of-asyncwrapper">When should I use BufferingWrapper instead of AsyncWrapper?</h3>
<p>Use <code>BufferingWrapper</code> when your target benefits from batch I/O -- most commonly database targets and HTTP-based targets. A single INSERT with 100 rows is much faster than 100 separate INSERTs. <code>AsyncWrapper</code> makes the caller non-blocking; <code>BufferingWrapper</code> reduces I/O round-trips. Use both together for the best of both: non-blocking caller plus batched writes.</p>
<h3 id="does-keepfileopentrue-break-log-rotation-on-linux">Does <code>keepFileOpen=&quot;true&quot;</code> break log rotation on Linux?</h3>
<p>On Linux, a file with an open handle can still be deleted (the inode remains until all handles are released). Log rotation tools like <code>logrotate</code> use the <code>copytruncate</code> option to work with applications that keep files open. NLog also supports <code>autoFlush=&quot;true&quot;</code> and the <code>ArchiveOldFileOnStartup</code> option. For containerized deployments, write to stdout and let the container runtime handle log collection -- which sidesteps file rotation entirely.</p>
<h3 id="how-do-i-measure-the-actual-throughput-of-my-nlog-configuration">How do I measure the actual throughput of my NLog configuration?</h3>
<p>Use BenchmarkDotNet with realistic message payloads and real targets (not null targets), and measure under concurrent load that matches your production request pattern. Single-threaded benchmarks underestimate contention on the file handle or queue. Test with multiple concurrent <code>Task</code> instances calling the logger to simulate real-world producer/consumer load on the <code>AsyncWrapper</code> queue.</p>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>NLog performance optimization comes down to three principles: move I/O off the calling thread with <code>AsyncWrapper</code>, reduce I/O round-trips with <code>BufferingWrapper</code>, and configure file targets to keep files open. Most applications can achieve sub-microsecond per-call logging latency with these tools properly configured.</p>
<p>The key takeaways:</p>
<ul>
<li><strong>AsyncWrapper is the first and most important optimization</strong> -- it turns every logging call from a blocking I/O operation into an in-memory queue enqueue</li>
<li><strong>Tune <code>queueLimit</code> and <code>overflowAction</code> per target</strong> based on the target's write speed and your tolerance for dropped events</li>
<li><strong>Combine AsyncWrapper + BufferingWrapper for database and HTTP targets</strong> to batch I/O and reduce network round-trips</li>
<li><strong><code>keepFileOpen=&quot;true&quot;</code> is free throughput</strong> for single-process file targets; on NLog 5.x, pair with <code>concurrentWrites=&quot;false&quot;</code> (NLog 6 removed <code>ConcurrentWrites</code> from <code>FileTarget</code>)</li>
<li><strong>Benchmark with BenchmarkDotNet under realistic concurrent load</strong> before and after changes</li>
</ul>
<p>Once you've optimized NLog for throughput, the next architectural question is often whether NLog is the right choice for your project. NLog and Serilog have different strengths -- NLog's rules-based routing is more powerful for complex multi-target scenarios, while Serilog's sink ecosystem is broader and its C# fluent API is more natural for teams that prefer code-first configuration.</p>
]]></content:encoded>
      <media:content url="https://devleader-d2f9ggbjfpdqcka7.z01.azurefd.net/media/nlog-performance-asyncwrapper-buffering-high-throughput.webp" />
    </item>
    <item>
      <guid isPermaLink="false">3f703f76-7f4b-4a04-851a-15a0f2a80fb7</guid>
      <link>https://www.devleader.ca/2026/08/09/nlog-rules-and-filters-routing-logs-in-net</link>
      <category>nlog rules</category>
      <category>nlog filters</category>
      <category>nlog routing</category>
      <category>nlog log levels</category>
      <category>nlog configuration csharp</category>
      <title>NLog Rules and Filters: Routing Logs in .NET</title>
      <pubDate>Sun, 09 Aug 2026 21:00:00 Z</pubDate>
      <description><![CDATA[<h1 id="nlog-rules-and-filters-routing-logs-in.net">NLog Rules and Filters: Routing Logs in .NET</h1>
<p><strong>NLog rules and filters</strong> give you surgical control over which log events reach which targets and under what conditions. Most developers know how to set a minimum log level, but NLog's rule engine goes much further -- you can route logs by logger name pattern, suppress entire namespaces, send warning-and-above to a separate file, or write only specific business events to a database while everything else goes to the console.</p>
<p>This guide covers every dimension of NLog's rules and filters system with practical C# examples. By the end, you'll understand how to silence the Microsoft.* noise that clutters development logs, how <code>final=&quot;true&quot;</code> stops rule evaluation early, how to use <code>when</code> filter conditions for fine-grained control, and how to design a multi-target routing strategy for a production ASP.NET Core application.</p>
<p>If you're new to NLog, start with package installation and basic configuration before working through the routing system -- getting a working logger first makes the rules examples below much easier to experiment with. The <a href="https://www.devleader.ca/2026/07/03/logging-in-net-the-complete-developers-guide">complete guide to logging in .NET</a> provides foundational context on how NLog fits into the broader .NET logging ecosystem alongside Serilog and Microsoft.Extensions.Logging.</p>
<h2 id="how-nlog-rules-work">How NLog Rules Work</h2>
<p>The <code>&lt;rules&gt;</code> section in NLog configuration defines a pipeline. NLog evaluates rules top-to-bottom for every log event. Each rule can match by logger name, level, or both, and directs matching events to one or more targets. Rules keep evaluating until a <code>final=&quot;true&quot;</code> rule matches -- at that point, evaluation stops for that log event.</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;rules&gt;
  &lt;!-- Rule 1: Microsoft.* logs at Warn+ go to file --&gt;
  &lt;logger name=&quot;Microsoft.*&quot; minlevel=&quot;Warn&quot; writeTo=&quot;file&quot; final=&quot;true&quot; /&gt;
  &lt;!-- Rule 2: Everything at Debug+ goes to console --&gt;
  &lt;logger name=&quot;*&quot; minlevel=&quot;Debug&quot; writeTo=&quot;console&quot; /&gt;
&lt;/rules&gt;
</code></pre>
</div>
<p>This configuration:</p>
<ol>
<li>Sends <code>Microsoft.*</code> events at Warn or above to the file target, then stops (no console)</li>
<li>Sends everything else at Debug or above to console</li>
</ol>
<p>Without <code>final=&quot;true&quot;</code> on the first rule, Microsoft.* Warn events would also hit the console.</p>
<h3 id="rules-are-ordered-and-cumulative">Rules Are Ordered and Cumulative</h3>
<p>Rules are <strong>not</strong> exclusive by default -- a single log event can match multiple rules and be sent to multiple targets. This is intentional: NLog lets you fan out the same event to a console target, a file target, and a database target simultaneously by having multiple non-final rules match it.</p>
<p>The order matters because <code>final=&quot;true&quot;</code> stops evaluation. Place more specific rules (with name patterns or level ranges) before more general ones.</p>
<h2 id="logger-name-matching">Logger Name Matching</h2>
<p>The <code>name</code> attribute on a rule supports glob-style patterns:</p>
<table class="table">
<thead>
<tr>
<th>Pattern</th>
<th>Matches</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>*</code></td>
<td>Everything</td>
</tr>
<tr>
<td><code>MyApp.*</code></td>
<td>Any logger starting with <code>MyApp.</code></td>
</tr>
<tr>
<td><code>MyApp.Services.*</code></td>
<td>Any logger in the Services namespace</td>
</tr>
<tr>
<td><code>Microsoft.*</code></td>
<td>Any logger starting with <code>Microsoft.</code></td>
</tr>
<tr>
<td><code>MyApp.Controllers.HomeController</code></td>
<td>Exact match only</td>
</tr>
</tbody>
</table>
<p>The logger name is the category name passed to <code>ILogger&lt;T&gt;</code>. For <code>ILogger&lt;HomeController&gt;</code>, the logger name is the fully qualified type name: <code>MyApp.Controllers.HomeController</code>.</p>
<h3 id="namespaced-routing-example">Namespaced Routing Example</h3>
<p>Here is a realistic routing setup for a production ASP.NET Core application that routes application logs, framework logs, and security audit logs to separate destinations:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;rules&gt;
  &lt;!-- Audit logs: SecurityAudit category → dedicated file, stop here --&gt;
  &lt;logger name=&quot;SecurityAudit&quot; minlevel=&quot;Info&quot; writeTo=&quot;auditFile&quot; final=&quot;true&quot; /&gt;

  &lt;!-- Framework noise: Debug/Info Microsoft.* and System.* → discard --&gt;
  &lt;logger name=&quot;Microsoft.*&quot; maxlevel=&quot;Info&quot; final=&quot;true&quot; /&gt;
  &lt;logger name=&quot;System.*&quot; maxlevel=&quot;Info&quot; final=&quot;true&quot; /&gt;

  &lt;!-- Framework warnings: Warn+ from Microsoft.* → general file only, stop here --&gt;
  &lt;logger name=&quot;Microsoft.*&quot; minlevel=&quot;Warn&quot; writeTo=&quot;file&quot; final=&quot;true&quot; /&gt;

  &lt;!-- Application logs: everything at Debug+ → console and rolling file --&gt;
  &lt;logger name=&quot;*&quot; minlevel=&quot;Debug&quot; writeTo=&quot;console,file&quot; /&gt;
&lt;/rules&gt;
</code></pre>
</div>
<p>The <code>maxlevel=&quot;Info&quot;</code> rule with no <code>writeTo</code> and <code>final=&quot;true&quot;</code> is the standard idiom for silencing a namespace entirely at a given level range. Because it has no target, the events are discarded. The <code>final=&quot;true&quot;</code> prevents them from falling through to the catch-all rule at the bottom.</p>
<h2 id="log-level-ranges">Log Level Ranges</h2>
<p>NLog rules support <code>minlevel</code>, <code>maxlevel</code>, <code>level</code>, and <code>levels</code> attributes for precise level control. Understanding the difference between these is important -- <code>minlevel</code> is the most commonly used and matches the specified level and everything above it, while <code>maxlevel</code> caps at the specified level and includes everything below it. You can combine both to target a specific range.</p>
<table class="table">
<thead>
<tr>
<th>Attribute</th>
<th>Meaning</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>minlevel</code></td>
<td>This level and above</td>
<td><code>minlevel=&quot;Warn&quot;</code> → Warn, Error, Fatal</td>
</tr>
<tr>
<td><code>maxlevel</code></td>
<td>This level and below</td>
<td><code>maxlevel=&quot;Info&quot;</code> → Trace, Debug, Info</td>
</tr>
<tr>
<td><code>level</code></td>
<td>Exactly this level</td>
<td><code>level=&quot;Error&quot;</code></td>
</tr>
<tr>
<td><code>levels</code></td>
<td>Comma-separated list</td>
<td><code>levels=&quot;Trace,Debug&quot;</code></td>
</tr>
</tbody>
</table>
<p>NLog's levels in ascending order: <code>Trace → Debug → Info → Warn → Error → Fatal</code>.</p>
<h3 id="level-based-fan-out">Level-Based Fan-Out</h3>
<p>A common pattern sends high-severity events to a real-time alerting target while all events go to persistent storage:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;rules&gt;
  &lt;!-- Critical errors → Slack/PagerDuty webhook --&gt;
  &lt;logger name=&quot;*&quot; minlevel=&quot;Error&quot; writeTo=&quot;alertWebhook&quot; /&gt;
  &lt;!-- Everything Info+ → rolling file (separate from above, both fire) --&gt;
  &lt;logger name=&quot;*&quot; minlevel=&quot;Info&quot; writeTo=&quot;rollingFile&quot; /&gt;
  &lt;!-- Debug+ → console (dev only, disabled in prod via config) --&gt;
  &lt;logger name=&quot;*&quot; minlevel=&quot;Debug&quot; writeTo=&quot;console&quot; /&gt;
&lt;/rules&gt;
</code></pre>
</div>
<p>Because none of these rules use <code>final=&quot;true&quot;</code>, an <code>Error</code> event matches all three rules and is sent to all three targets simultaneously. This is the intended behavior -- you want errors in both the alert webhook and the rolling file.</p>
<h2 id="the-final-attribute">The <code>final</code> Attribute</h2>
<p><code>final=&quot;true&quot;</code> stops NLog from evaluating further rules for the current log event once this rule matches. It is the single most important tool for preventing log duplication and implementing &quot;route-and-stop&quot; logic.</p>
<p>Without <code>final</code>, every non-final rule that matches a log event will process it. With <code>final</code>, NLog stops at the first matching rule with <code>final=&quot;true&quot;</code>.</p>
<h3 id="silencing-microsoft-internals">Silencing Microsoft Internals</h3>
<p>The most common use of <code>final</code> is suppressing ASP.NET Core's verbose internal logging:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;rules&gt;
  &lt;!-- Suppress Microsoft.* Debug and Info entirely --&gt;
  &lt;logger name=&quot;Microsoft.*&quot; maxlevel=&quot;Info&quot; final=&quot;true&quot; /&gt;
  &lt;!-- Suppress System.* Debug and Info entirely --&gt;
  &lt;logger name=&quot;System.*&quot; maxlevel=&quot;Info&quot; final=&quot;true&quot; /&gt;
  &lt;!-- Everything else at Debug+ → console and file --&gt;
  &lt;logger name=&quot;*&quot; minlevel=&quot;Debug&quot; writeTo=&quot;console,file&quot; /&gt;
&lt;/rules&gt;
</code></pre>
</div>
<p>Notice the rules have no <code>writeTo</code> attribute -- they match and terminate without writing anywhere. This is how you discard log events in NLog. The <code>final=&quot;true&quot;</code> then prevents the discarded events from reaching the catch-all rule below.</p>
<h3 id="route-and-stop-pattern">Route-and-Stop Pattern</h3>
<p>Use <code>final</code> when a category of logs has special handling and must not also flow through general routing. Without <code>final</code>, the event would continue matching subsequent rules and end up in targets that were not intended for that category -- leading to duplicate log entries or cluttered outputs.</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;rules&gt;
  &lt;!-- Performance metrics → metrics target only, stop here --&gt;
  &lt;logger name=&quot;Performance.*&quot; minlevel=&quot;Info&quot; writeTo=&quot;metricsTarget&quot; final=&quot;true&quot; /&gt;
  &lt;!-- Background job logs → background jobs file only, stop here --&gt;
  &lt;logger name=&quot;*.BackgroundJob&quot; minlevel=&quot;Debug&quot; writeTo=&quot;backgroundFile&quot; final=&quot;true&quot; /&gt;
  &lt;!-- General application → default targets --&gt;
  &lt;logger name=&quot;*&quot; minlevel=&quot;Info&quot; writeTo=&quot;console,file&quot; /&gt;
&lt;/rules&gt;
</code></pre>
</div>
<p>Each specialized category is handled in isolation. The catch-all at the bottom never sees Performance or BackgroundJob log events.</p>
<h2 id="filter-conditions-with-when">Filter Conditions with <code>when</code></h2>
<p>The <code>when</code> filter provides fine-grained, expression-based filtering within a rule. While <code>name</code> and level attributes filter on the rule level, <code>when</code> filters on a per-event basis using NLog's condition language.</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;rules&gt;
  &lt;logger name=&quot;*&quot; minlevel=&quot;Debug&quot; writeTo=&quot;file&quot;&gt;
    &lt;filters&gt;
      &lt;!-- Skip health check endpoint noise --&gt;
      &lt;when condition=&quot;contains('${aspnet-request-url}', '/health')&quot; action=&quot;Ignore&quot; /&gt;
    &lt;/filters&gt;
  &lt;/logger&gt;
&lt;/rules&gt;
</code></pre>
</div>
<p>The <code>action</code> attribute controls what happens when the condition is true:</p>
<table class="table">
<thead>
<tr>
<th>Action</th>
<th>Behavior</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Ignore</code></td>
<td>Discard this log event for this rule</td>
</tr>
<tr>
<td><code>IgnoreFinal</code></td>
<td>Discard and stop all rule evaluation</td>
</tr>
<tr>
<td><code>Log</code></td>
<td>Write this event (default, inverts the filter)</td>
</tr>
<tr>
<td><code>LogFinal</code></td>
<td>Write this event and stop all rule evaluation</td>
</tr>
<tr>
<td><code>Neutral</code></td>
<td>Defer to next filter in chain</td>
</tr>
</tbody>
</table>
<h3 id="condition-language-basics">Condition Language Basics</h3>
<p>NLog's condition language is a mini expression language built into the logging library -- no external dependencies required. It supports comparisons, string operations, and boolean logic, and can reference any built-in renderer value including request URL, message content, and log level. The following expressions illustrate common patterns you can use:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code>level == LogLevel.Error
level &gt;= LogLevel.Warn
message contains 'payment'
logger starts-with 'MyApp.Services'
contains('${aspnet-request-url}', '/healthz')
length(message) &gt; 500
</code></pre>
</div>
<p>Conditions can be combined with <code>and</code>, <code>or</code>, <code>not</code>:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;when condition=&quot;level &gt;= LogLevel.Warn and contains(logger, 'Database')&quot;
      action=&quot;LogFinal&quot; /&gt;
</code></pre>
</div><h3 id="practical-filter-example-deduplication">Practical Filter Example: Deduplication</h3>
<p>In a high-throughput service, the same error can fire thousands of times per second. Use a <code>when</code> filter with a custom property to limit repetition. The <code>ThrottleKey</code> property must be set on the log event scope -- use <code>NLog.ScopeContext.PushProperty</code> to attach it before logging:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-csharp">// Set ThrottleKey on the ambient scope so the filter condition can read it
using (NLog.ScopeContext.PushProperty(&quot;ThrottleKey&quot;, &quot;payment-timeout&quot;))
{
    _logger.LogError(&quot;Payment gateway timeout for {OrderId}&quot;, orderId);
}
</code></pre>
</div><div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;rules&gt;
  &lt;logger name=&quot;*&quot; minlevel=&quot;Warn&quot; writeTo=&quot;alertFile&quot;&gt;
    &lt;filters defaultAction=&quot;Log&quot;&gt;
      &lt;!-- Only log events that have ThrottleKey set (i.e., are throttle-eligible) --&gt;
      &lt;when condition=&quot;${event-properties:item=ThrottleKey} == ''&quot;
            action=&quot;Log&quot; /&gt;
    &lt;/filters&gt;
  &lt;/logger&gt;
&lt;/rules&gt;
</code></pre>
</div>
<p>This pattern is a starting point -- NLog's <code>WhenRepeatedFilter</code> (available as a NuGet extension) provides a more complete implementation with built-in time-based deduplication.</p>
<h2 id="json-configuration-appsettings.json">JSON Configuration (appsettings.json)</h2>
<p>Everything above can be expressed in <code>appsettings.json</code> for ASP.NET Core applications. The JSON format is more CI/CD-friendly since DevOps teams can inject values via environment variables without touching XML files.</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-json">{
  &quot;NLog&quot;: {
    &quot;rules&quot;: [
      {
        &quot;logger&quot;: &quot;Microsoft.*&quot;,
        &quot;maxlevel&quot;: &quot;Info&quot;,
        &quot;final&quot;: true
      },
      {
        &quot;logger&quot;: &quot;System.*&quot;,
        &quot;maxlevel&quot;: &quot;Info&quot;,
        &quot;final&quot;: true
      },
      {
        &quot;logger&quot;: &quot;Microsoft.*&quot;,
        &quot;minlevel&quot;: &quot;Warn&quot;,
        &quot;writeTo&quot;: &quot;file&quot;,
        &quot;final&quot;: true
      },
      {
        &quot;logger&quot;: &quot;*&quot;,
        &quot;minlevel&quot;: &quot;Debug&quot;,
        &quot;writeTo&quot;: &quot;console,file&quot;
      }
    ]
  }
}
</code></pre>
</div>
<p>The <code>final</code> property in JSON corresponds to <code>final=&quot;true&quot;</code> in XML. The behavior is identical -- NLog evaluates JSON and XML configurations the same way at runtime.</p>
<h2 id="environment-specific-rules">Environment-Specific Rules</h2>
<p>A real production setup often needs different rules for development vs. production environments. Development teams want verbose output with Trace and Debug enabled for fast feedback, while production should be quiet and efficient -- logging Info and above from application code, and suppressing framework Debug/Info entirely to reduce storage costs and improve signal quality. There are two main approaches to managing environment-specific rules.</p>
<h3 id="approach-1-environment-variables-in-config">Approach 1: Environment Variables in Config</h3>
<blockquote class="blockquote">
<p><strong>Important:</strong> <code>minlevel</code> in NLog rules is <strong>not a layout</strong> -- it expects a literal level value (<code>Trace</code>, <code>Debug</code>, <code>Info</code>, <code>Warn</code>, <code>Error</code>, <code>Fatal</code>). NLog does not evaluate layout renderers or ternary expressions in <code>minlevel</code>. A config like <code>minlevel=&quot;${environment:...}=Development ? Trace : Info&quot;</code> is invalid and will be rejected or silently ignored at runtime.</p>
<p>If you need environment-conditional minimum levels in a single config file, use NLog's <code>&lt;variable&gt;</code> support with <code>${gdc:item=...}</code> or set a NLog global diagnostic context value from <code>Program.cs</code> before loading configuration. In practice, Approach 2 (separate config files) is simpler and less error-prone.</p>
</blockquote>
<h3 id="approach-2-separate-config-files">Approach 2: Separate Config Files</h3>
<p>The simpler and more maintainable approach is a separate <code>nlog.Production.config</code> with tighter rules, loaded by your deployment pipeline. During development, <code>nlog.Development.config</code> uses <code>minlevel=&quot;Debug&quot;</code> everywhere. In production, <code>nlog.Production.config</code> uses <code>minlevel=&quot;Info&quot;</code> for application code and suppresses all Microsoft.* Debug/Info. This avoids complex condition expressions and makes each environment's routing obvious.</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-csharp">// In Program.cs, resolve config file by environment
var env = builder.Environment.EnvironmentName;
var configFile = $&quot;nlog.{env}.config&quot;;
if (!File.Exists(configFile)) configFile = &quot;nlog.config&quot;; // fallback

LogManager.Setup().LoadConfigurationFromFile(configFile);
</code></pre>
</div><h2 id="complete-production-rules-template">Complete Production Rules Template</h2>
<p>Here is a complete rules configuration for a production ASP.NET Core application with console, rolling file, and error-only alert targets:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;rules&gt;
  &lt;!-- 1. Suppress EF Core query spam (Debug/Info) --&gt;
  &lt;logger name=&quot;Microsoft.EntityFrameworkCore.Database.Command&quot;
          maxlevel=&quot;Info&quot; final=&quot;true&quot; /&gt;

  &lt;!-- 2. Suppress all Microsoft.*/System.* Debug and Info --&gt;
  &lt;logger name=&quot;Microsoft.*&quot; maxlevel=&quot;Info&quot; final=&quot;true&quot; /&gt;
  &lt;logger name=&quot;System.*&quot; maxlevel=&quot;Info&quot; final=&quot;true&quot; /&gt;

  &lt;!-- 3. Microsoft.* Warn+ → file only (no console) --&gt;
  &lt;logger name=&quot;Microsoft.*&quot; minlevel=&quot;Warn&quot; writeTo=&quot;file&quot; final=&quot;true&quot; /&gt;
  &lt;logger name=&quot;System.*&quot; minlevel=&quot;Warn&quot; writeTo=&quot;file&quot; final=&quot;true&quot; /&gt;

  &lt;!-- 4. Application errors → dedicated error file (in addition to rules below) --&gt;
  &lt;logger name=&quot;MyApp.*&quot; minlevel=&quot;Error&quot; writeTo=&quot;errorFile&quot; /&gt;

  &lt;!-- 5. All application logs → console + rolling file --&gt;
  &lt;logger name=&quot;*&quot; minlevel=&quot;Info&quot; writeTo=&quot;console,file&quot; /&gt;
&lt;/rules&gt;
</code></pre>
</div>
<p>This layered approach eliminates framework noise from the console, keeps framework warnings in the file for postmortem analysis, and maintains a separate error file for fast filtering during incidents. NLog's target system -- file, console, database, Seq, and others -- is what backs the <code>writeTo</code> attribute in each rule.</p>
<h2 id="frequently-asked-questions">Frequently Asked Questions</h2>
<h3 id="what-does-finaltrue-do-in-nlog-rules">What does <code>final=&quot;true&quot;</code> do in NLog rules?</h3>
<p><code>final=&quot;true&quot;</code> stops NLog from evaluating further rules for the current log event once the rule with <code>final</code> matches. Without it, NLog continues evaluating all remaining rules and sends the event to any additional matching targets. Use <code>final</code> when you want route-and-stop behavior -- the event is handled by exactly one rule and goes no further.</p>
<h3 id="how-do-i-silence-microsoft.logs-without-affecting-my-application-logs">How do I silence Microsoft.* logs without affecting my application logs?</h3>
<p>Add two rules before your catch-all: one that matches <code>Microsoft.*</code> with <code>maxlevel=&quot;Info&quot;</code> and <code>final=&quot;true&quot;</code> (no <code>writeTo</code> -- this discards Debug/Info silently), and one that matches <code>Microsoft.*</code> with <code>minlevel=&quot;Warn&quot;</code> and routes to a file target with <code>final=&quot;true&quot;</code>. This sends framework warnings to file without showing them on the console, and completely drops Debug/Info noise.</p>
<h3 id="can-a-single-log-event-match-multiple-nlog-rules">Can a single log event match multiple NLog rules?</h3>
<p>Yes -- by default, NLog evaluates all rules top-to-bottom and a single event can match and be written by multiple rules simultaneously. This is how you fan out to multiple targets. The only exception is when a matching rule has <code>final=&quot;true&quot;</code>, which stops evaluation after that rule.</p>
<h3 id="what-is-the-difference-between-level-and-minlevel-in-nlog-rules">What is the difference between <code>level</code> and <code>minlevel</code> in NLog rules?</h3>
<p><code>level</code> matches exactly one log level (e.g., <code>level=&quot;Error&quot;</code> matches only Error, not Fatal). <code>minlevel</code> matches that level and all higher levels (e.g., <code>minlevel=&quot;Warn&quot;</code> matches Warn, Error, and Fatal). <code>maxlevel</code> matches that level and all lower levels. Use <code>levels=&quot;Error,Fatal&quot;</code> when you need a non-contiguous set.</p>
<h3 id="how-do-i-use-when-filters-to-ignore-health-check-log-events">How do I use <code>when</code> filters to ignore health check log events?</h3>
<p>Add a <code>&lt;filters&gt;</code> block inside the rule and use a <code>when</code> condition that matches the URL or message content. For ASP.NET Core health checks, <code>contains('${aspnet-request-url}', '/health')</code> with <code>action=&quot;Ignore&quot;</code> drops events from health check endpoints before they are written to any target. Install <code>NLog.Web.AspNetCore</code> to enable the <code>aspnet-request-url</code> renderer.</p>
<h3 id="how-do-i-route-logs-to-different-files-by-log-level">How do I route logs to different files by log level?</h3>
<p>Use multiple non-final rules, each with <code>writeTo</code> pointing to a different target, and level constraints that don't overlap. For example: a rule with <code>minlevel=&quot;Error&quot;</code> and <code>writeTo=&quot;errorFile&quot;</code>, and a separate rule with <code>minlevel=&quot;Info&quot; maxlevel=&quot;Warn&quot;</code> and <code>writeTo=&quot;infoFile&quot;</code>. Since neither rule uses <code>final</code>, Error events match the first rule only (because Errors are above maxlevel=Warn), while Warn and Info events match only the second rule.</p>
<h3 id="should-i-configure-nlog-rules-in-xml-or-json">Should I configure NLog rules in XML or JSON?</h3>
<p>Both are functionally equivalent. Use JSON (<code>appsettings.json</code>) for ASP.NET Core applications where you want environment-specific overrides via <code>appsettings.Production.json</code> or environment variables. Use XML (<code>nlog.config</code>) when your operations team is more comfortable with it or when you need NLog-specific features like dynamic reloading via <code>autoReload=&quot;true&quot;</code>. In ASP.NET Core specifically, integrating NLog rules with the host's middleware pipeline -- for instance, to enrich log context per-request -- works cleanly alongside <a href="https://www.devleader.ca/2024/01/31/custom-middleware-in-aspnet-core-how-to-harness-the-power">custom middleware</a>.</p>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>NLog's rules and filters system is one of its biggest strengths over simpler logging libraries. The combination of glob name matching, level ranges, <code>final=&quot;true&quot;</code> short-circuits, and expression-based <code>when</code> filters lets you implement arbitrarily complex routing logic without any C# code -- just configuration.</p>
<p>The key patterns to internalize:</p>
<ul>
<li><strong>Silence by namespace</strong>: <code>name=&quot;Microsoft.*&quot; maxlevel=&quot;Info&quot; final=&quot;true&quot;</code> (no writeTo)</li>
<li><strong>Route and stop</strong>: match a category, send it to a target, add <code>final=&quot;true&quot;</code></li>
<li><strong>Fan-out</strong>: multiple non-final rules all matching <code>name=&quot;*&quot;</code> send to multiple targets simultaneously</li>
<li><strong>Condition filtering</strong>: <code>when</code> conditions inside rules for per-event decisions</li>
</ul>
<p>Complex rule chains have measurable performance implications -- especially when rules fan out to multiple slow targets synchronously. Wrapping synchronous targets in an <code>AsyncWrapper</code> and benchmarking with <a href="https://www.devleader.ca/2024/03/05/how-to-use-benchmarkdotnet-6-simple-performance-boosting-tips-to-get-started">BenchmarkDotNet</a> are the key techniques for keeping NLog from becoming a throughput bottleneck in high-traffic applications. For comparison on how Serilog approaches similar routing decisions, the <a href="https://www.devleader.ca/2026/07/05/serilog-in-net-complete-guide-to-structured-logging">Serilog in .NET complete guide</a> covers the equivalent sink and filter configuration patterns.</p>
]]></description>
      <content:encoded><![CDATA[<h1 id="nlog-rules-and-filters-routing-logs-in.net">NLog Rules and Filters: Routing Logs in .NET</h1>
<p><strong>NLog rules and filters</strong> give you surgical control over which log events reach which targets and under what conditions. Most developers know how to set a minimum log level, but NLog's rule engine goes much further -- you can route logs by logger name pattern, suppress entire namespaces, send warning-and-above to a separate file, or write only specific business events to a database while everything else goes to the console.</p>
<p>This guide covers every dimension of NLog's rules and filters system with practical C# examples. By the end, you'll understand how to silence the Microsoft.* noise that clutters development logs, how <code>final=&quot;true&quot;</code> stops rule evaluation early, how to use <code>when</code> filter conditions for fine-grained control, and how to design a multi-target routing strategy for a production ASP.NET Core application.</p>
<p>If you're new to NLog, start with package installation and basic configuration before working through the routing system -- getting a working logger first makes the rules examples below much easier to experiment with. The <a href="https://www.devleader.ca/2026/07/03/logging-in-net-the-complete-developers-guide">complete guide to logging in .NET</a> provides foundational context on how NLog fits into the broader .NET logging ecosystem alongside Serilog and Microsoft.Extensions.Logging.</p>
<h2 id="how-nlog-rules-work">How NLog Rules Work</h2>
<p>The <code>&lt;rules&gt;</code> section in NLog configuration defines a pipeline. NLog evaluates rules top-to-bottom for every log event. Each rule can match by logger name, level, or both, and directs matching events to one or more targets. Rules keep evaluating until a <code>final=&quot;true&quot;</code> rule matches -- at that point, evaluation stops for that log event.</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;rules&gt;
  &lt;!-- Rule 1: Microsoft.* logs at Warn+ go to file --&gt;
  &lt;logger name=&quot;Microsoft.*&quot; minlevel=&quot;Warn&quot; writeTo=&quot;file&quot; final=&quot;true&quot; /&gt;
  &lt;!-- Rule 2: Everything at Debug+ goes to console --&gt;
  &lt;logger name=&quot;*&quot; minlevel=&quot;Debug&quot; writeTo=&quot;console&quot; /&gt;
&lt;/rules&gt;
</code></pre>
</div>
<p>This configuration:</p>
<ol>
<li>Sends <code>Microsoft.*</code> events at Warn or above to the file target, then stops (no console)</li>
<li>Sends everything else at Debug or above to console</li>
</ol>
<p>Without <code>final=&quot;true&quot;</code> on the first rule, Microsoft.* Warn events would also hit the console.</p>
<h3 id="rules-are-ordered-and-cumulative">Rules Are Ordered and Cumulative</h3>
<p>Rules are <strong>not</strong> exclusive by default -- a single log event can match multiple rules and be sent to multiple targets. This is intentional: NLog lets you fan out the same event to a console target, a file target, and a database target simultaneously by having multiple non-final rules match it.</p>
<p>The order matters because <code>final=&quot;true&quot;</code> stops evaluation. Place more specific rules (with name patterns or level ranges) before more general ones.</p>
<h2 id="logger-name-matching">Logger Name Matching</h2>
<p>The <code>name</code> attribute on a rule supports glob-style patterns:</p>
<table class="table">
<thead>
<tr>
<th>Pattern</th>
<th>Matches</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>*</code></td>
<td>Everything</td>
</tr>
<tr>
<td><code>MyApp.*</code></td>
<td>Any logger starting with <code>MyApp.</code></td>
</tr>
<tr>
<td><code>MyApp.Services.*</code></td>
<td>Any logger in the Services namespace</td>
</tr>
<tr>
<td><code>Microsoft.*</code></td>
<td>Any logger starting with <code>Microsoft.</code></td>
</tr>
<tr>
<td><code>MyApp.Controllers.HomeController</code></td>
<td>Exact match only</td>
</tr>
</tbody>
</table>
<p>The logger name is the category name passed to <code>ILogger&lt;T&gt;</code>. For <code>ILogger&lt;HomeController&gt;</code>, the logger name is the fully qualified type name: <code>MyApp.Controllers.HomeController</code>.</p>
<h3 id="namespaced-routing-example">Namespaced Routing Example</h3>
<p>Here is a realistic routing setup for a production ASP.NET Core application that routes application logs, framework logs, and security audit logs to separate destinations:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;rules&gt;
  &lt;!-- Audit logs: SecurityAudit category → dedicated file, stop here --&gt;
  &lt;logger name=&quot;SecurityAudit&quot; minlevel=&quot;Info&quot; writeTo=&quot;auditFile&quot; final=&quot;true&quot; /&gt;

  &lt;!-- Framework noise: Debug/Info Microsoft.* and System.* → discard --&gt;
  &lt;logger name=&quot;Microsoft.*&quot; maxlevel=&quot;Info&quot; final=&quot;true&quot; /&gt;
  &lt;logger name=&quot;System.*&quot; maxlevel=&quot;Info&quot; final=&quot;true&quot; /&gt;

  &lt;!-- Framework warnings: Warn+ from Microsoft.* → general file only, stop here --&gt;
  &lt;logger name=&quot;Microsoft.*&quot; minlevel=&quot;Warn&quot; writeTo=&quot;file&quot; final=&quot;true&quot; /&gt;

  &lt;!-- Application logs: everything at Debug+ → console and rolling file --&gt;
  &lt;logger name=&quot;*&quot; minlevel=&quot;Debug&quot; writeTo=&quot;console,file&quot; /&gt;
&lt;/rules&gt;
</code></pre>
</div>
<p>The <code>maxlevel=&quot;Info&quot;</code> rule with no <code>writeTo</code> and <code>final=&quot;true&quot;</code> is the standard idiom for silencing a namespace entirely at a given level range. Because it has no target, the events are discarded. The <code>final=&quot;true&quot;</code> prevents them from falling through to the catch-all rule at the bottom.</p>
<h2 id="log-level-ranges">Log Level Ranges</h2>
<p>NLog rules support <code>minlevel</code>, <code>maxlevel</code>, <code>level</code>, and <code>levels</code> attributes for precise level control. Understanding the difference between these is important -- <code>minlevel</code> is the most commonly used and matches the specified level and everything above it, while <code>maxlevel</code> caps at the specified level and includes everything below it. You can combine both to target a specific range.</p>
<table class="table">
<thead>
<tr>
<th>Attribute</th>
<th>Meaning</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>minlevel</code></td>
<td>This level and above</td>
<td><code>minlevel=&quot;Warn&quot;</code> → Warn, Error, Fatal</td>
</tr>
<tr>
<td><code>maxlevel</code></td>
<td>This level and below</td>
<td><code>maxlevel=&quot;Info&quot;</code> → Trace, Debug, Info</td>
</tr>
<tr>
<td><code>level</code></td>
<td>Exactly this level</td>
<td><code>level=&quot;Error&quot;</code></td>
</tr>
<tr>
<td><code>levels</code></td>
<td>Comma-separated list</td>
<td><code>levels=&quot;Trace,Debug&quot;</code></td>
</tr>
</tbody>
</table>
<p>NLog's levels in ascending order: <code>Trace → Debug → Info → Warn → Error → Fatal</code>.</p>
<h3 id="level-based-fan-out">Level-Based Fan-Out</h3>
<p>A common pattern sends high-severity events to a real-time alerting target while all events go to persistent storage:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;rules&gt;
  &lt;!-- Critical errors → Slack/PagerDuty webhook --&gt;
  &lt;logger name=&quot;*&quot; minlevel=&quot;Error&quot; writeTo=&quot;alertWebhook&quot; /&gt;
  &lt;!-- Everything Info+ → rolling file (separate from above, both fire) --&gt;
  &lt;logger name=&quot;*&quot; minlevel=&quot;Info&quot; writeTo=&quot;rollingFile&quot; /&gt;
  &lt;!-- Debug+ → console (dev only, disabled in prod via config) --&gt;
  &lt;logger name=&quot;*&quot; minlevel=&quot;Debug&quot; writeTo=&quot;console&quot; /&gt;
&lt;/rules&gt;
</code></pre>
</div>
<p>Because none of these rules use <code>final=&quot;true&quot;</code>, an <code>Error</code> event matches all three rules and is sent to all three targets simultaneously. This is the intended behavior -- you want errors in both the alert webhook and the rolling file.</p>
<h2 id="the-final-attribute">The <code>final</code> Attribute</h2>
<p><code>final=&quot;true&quot;</code> stops NLog from evaluating further rules for the current log event once this rule matches. It is the single most important tool for preventing log duplication and implementing &quot;route-and-stop&quot; logic.</p>
<p>Without <code>final</code>, every non-final rule that matches a log event will process it. With <code>final</code>, NLog stops at the first matching rule with <code>final=&quot;true&quot;</code>.</p>
<h3 id="silencing-microsoft-internals">Silencing Microsoft Internals</h3>
<p>The most common use of <code>final</code> is suppressing ASP.NET Core's verbose internal logging:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;rules&gt;
  &lt;!-- Suppress Microsoft.* Debug and Info entirely --&gt;
  &lt;logger name=&quot;Microsoft.*&quot; maxlevel=&quot;Info&quot; final=&quot;true&quot; /&gt;
  &lt;!-- Suppress System.* Debug and Info entirely --&gt;
  &lt;logger name=&quot;System.*&quot; maxlevel=&quot;Info&quot; final=&quot;true&quot; /&gt;
  &lt;!-- Everything else at Debug+ → console and file --&gt;
  &lt;logger name=&quot;*&quot; minlevel=&quot;Debug&quot; writeTo=&quot;console,file&quot; /&gt;
&lt;/rules&gt;
</code></pre>
</div>
<p>Notice the rules have no <code>writeTo</code> attribute -- they match and terminate without writing anywhere. This is how you discard log events in NLog. The <code>final=&quot;true&quot;</code> then prevents the discarded events from reaching the catch-all rule below.</p>
<h3 id="route-and-stop-pattern">Route-and-Stop Pattern</h3>
<p>Use <code>final</code> when a category of logs has special handling and must not also flow through general routing. Without <code>final</code>, the event would continue matching subsequent rules and end up in targets that were not intended for that category -- leading to duplicate log entries or cluttered outputs.</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;rules&gt;
  &lt;!-- Performance metrics → metrics target only, stop here --&gt;
  &lt;logger name=&quot;Performance.*&quot; minlevel=&quot;Info&quot; writeTo=&quot;metricsTarget&quot; final=&quot;true&quot; /&gt;
  &lt;!-- Background job logs → background jobs file only, stop here --&gt;
  &lt;logger name=&quot;*.BackgroundJob&quot; minlevel=&quot;Debug&quot; writeTo=&quot;backgroundFile&quot; final=&quot;true&quot; /&gt;
  &lt;!-- General application → default targets --&gt;
  &lt;logger name=&quot;*&quot; minlevel=&quot;Info&quot; writeTo=&quot;console,file&quot; /&gt;
&lt;/rules&gt;
</code></pre>
</div>
<p>Each specialized category is handled in isolation. The catch-all at the bottom never sees Performance or BackgroundJob log events.</p>
<h2 id="filter-conditions-with-when">Filter Conditions with <code>when</code></h2>
<p>The <code>when</code> filter provides fine-grained, expression-based filtering within a rule. While <code>name</code> and level attributes filter on the rule level, <code>when</code> filters on a per-event basis using NLog's condition language.</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;rules&gt;
  &lt;logger name=&quot;*&quot; minlevel=&quot;Debug&quot; writeTo=&quot;file&quot;&gt;
    &lt;filters&gt;
      &lt;!-- Skip health check endpoint noise --&gt;
      &lt;when condition=&quot;contains('${aspnet-request-url}', '/health')&quot; action=&quot;Ignore&quot; /&gt;
    &lt;/filters&gt;
  &lt;/logger&gt;
&lt;/rules&gt;
</code></pre>
</div>
<p>The <code>action</code> attribute controls what happens when the condition is true:</p>
<table class="table">
<thead>
<tr>
<th>Action</th>
<th>Behavior</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Ignore</code></td>
<td>Discard this log event for this rule</td>
</tr>
<tr>
<td><code>IgnoreFinal</code></td>
<td>Discard and stop all rule evaluation</td>
</tr>
<tr>
<td><code>Log</code></td>
<td>Write this event (default, inverts the filter)</td>
</tr>
<tr>
<td><code>LogFinal</code></td>
<td>Write this event and stop all rule evaluation</td>
</tr>
<tr>
<td><code>Neutral</code></td>
<td>Defer to next filter in chain</td>
</tr>
</tbody>
</table>
<h3 id="condition-language-basics">Condition Language Basics</h3>
<p>NLog's condition language is a mini expression language built into the logging library -- no external dependencies required. It supports comparisons, string operations, and boolean logic, and can reference any built-in renderer value including request URL, message content, and log level. The following expressions illustrate common patterns you can use:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code>level == LogLevel.Error
level &gt;= LogLevel.Warn
message contains 'payment'
logger starts-with 'MyApp.Services'
contains('${aspnet-request-url}', '/healthz')
length(message) &gt; 500
</code></pre>
</div>
<p>Conditions can be combined with <code>and</code>, <code>or</code>, <code>not</code>:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;when condition=&quot;level &gt;= LogLevel.Warn and contains(logger, 'Database')&quot;
      action=&quot;LogFinal&quot; /&gt;
</code></pre>
</div><h3 id="practical-filter-example-deduplication">Practical Filter Example: Deduplication</h3>
<p>In a high-throughput service, the same error can fire thousands of times per second. Use a <code>when</code> filter with a custom property to limit repetition. The <code>ThrottleKey</code> property must be set on the log event scope -- use <code>NLog.ScopeContext.PushProperty</code> to attach it before logging:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-csharp">// Set ThrottleKey on the ambient scope so the filter condition can read it
using (NLog.ScopeContext.PushProperty(&quot;ThrottleKey&quot;, &quot;payment-timeout&quot;))
{
    _logger.LogError(&quot;Payment gateway timeout for {OrderId}&quot;, orderId);
}
</code></pre>
</div><div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;rules&gt;
  &lt;logger name=&quot;*&quot; minlevel=&quot;Warn&quot; writeTo=&quot;alertFile&quot;&gt;
    &lt;filters defaultAction=&quot;Log&quot;&gt;
      &lt;!-- Only log events that have ThrottleKey set (i.e., are throttle-eligible) --&gt;
      &lt;when condition=&quot;${event-properties:item=ThrottleKey} == ''&quot;
            action=&quot;Log&quot; /&gt;
    &lt;/filters&gt;
  &lt;/logger&gt;
&lt;/rules&gt;
</code></pre>
</div>
<p>This pattern is a starting point -- NLog's <code>WhenRepeatedFilter</code> (available as a NuGet extension) provides a more complete implementation with built-in time-based deduplication.</p>
<h2 id="json-configuration-appsettings.json">JSON Configuration (appsettings.json)</h2>
<p>Everything above can be expressed in <code>appsettings.json</code> for ASP.NET Core applications. The JSON format is more CI/CD-friendly since DevOps teams can inject values via environment variables without touching XML files.</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-json">{
  &quot;NLog&quot;: {
    &quot;rules&quot;: [
      {
        &quot;logger&quot;: &quot;Microsoft.*&quot;,
        &quot;maxlevel&quot;: &quot;Info&quot;,
        &quot;final&quot;: true
      },
      {
        &quot;logger&quot;: &quot;System.*&quot;,
        &quot;maxlevel&quot;: &quot;Info&quot;,
        &quot;final&quot;: true
      },
      {
        &quot;logger&quot;: &quot;Microsoft.*&quot;,
        &quot;minlevel&quot;: &quot;Warn&quot;,
        &quot;writeTo&quot;: &quot;file&quot;,
        &quot;final&quot;: true
      },
      {
        &quot;logger&quot;: &quot;*&quot;,
        &quot;minlevel&quot;: &quot;Debug&quot;,
        &quot;writeTo&quot;: &quot;console,file&quot;
      }
    ]
  }
}
</code></pre>
</div>
<p>The <code>final</code> property in JSON corresponds to <code>final=&quot;true&quot;</code> in XML. The behavior is identical -- NLog evaluates JSON and XML configurations the same way at runtime.</p>
<h2 id="environment-specific-rules">Environment-Specific Rules</h2>
<p>A real production setup often needs different rules for development vs. production environments. Development teams want verbose output with Trace and Debug enabled for fast feedback, while production should be quiet and efficient -- logging Info and above from application code, and suppressing framework Debug/Info entirely to reduce storage costs and improve signal quality. There are two main approaches to managing environment-specific rules.</p>
<h3 id="approach-1-environment-variables-in-config">Approach 1: Environment Variables in Config</h3>
<blockquote class="blockquote">
<p><strong>Important:</strong> <code>minlevel</code> in NLog rules is <strong>not a layout</strong> -- it expects a literal level value (<code>Trace</code>, <code>Debug</code>, <code>Info</code>, <code>Warn</code>, <code>Error</code>, <code>Fatal</code>). NLog does not evaluate layout renderers or ternary expressions in <code>minlevel</code>. A config like <code>minlevel=&quot;${environment:...}=Development ? Trace : Info&quot;</code> is invalid and will be rejected or silently ignored at runtime.</p>
<p>If you need environment-conditional minimum levels in a single config file, use NLog's <code>&lt;variable&gt;</code> support with <code>${gdc:item=...}</code> or set a NLog global diagnostic context value from <code>Program.cs</code> before loading configuration. In practice, Approach 2 (separate config files) is simpler and less error-prone.</p>
</blockquote>
<h3 id="approach-2-separate-config-files">Approach 2: Separate Config Files</h3>
<p>The simpler and more maintainable approach is a separate <code>nlog.Production.config</code> with tighter rules, loaded by your deployment pipeline. During development, <code>nlog.Development.config</code> uses <code>minlevel=&quot;Debug&quot;</code> everywhere. In production, <code>nlog.Production.config</code> uses <code>minlevel=&quot;Info&quot;</code> for application code and suppresses all Microsoft.* Debug/Info. This avoids complex condition expressions and makes each environment's routing obvious.</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-csharp">// In Program.cs, resolve config file by environment
var env = builder.Environment.EnvironmentName;
var configFile = $&quot;nlog.{env}.config&quot;;
if (!File.Exists(configFile)) configFile = &quot;nlog.config&quot;; // fallback

LogManager.Setup().LoadConfigurationFromFile(configFile);
</code></pre>
</div><h2 id="complete-production-rules-template">Complete Production Rules Template</h2>
<p>Here is a complete rules configuration for a production ASP.NET Core application with console, rolling file, and error-only alert targets:</p>
<div class="position-relative"><button class="btn btn-sm position-absolute top-0 end-0 m-2 border border-primary text-primary copy-btn"
        type="button"
        aria-label="Copy code"
        onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)">
        <i class="copy" aria-hidden="true"></i>
</button>
<pre><code class="language-xml">&lt;rules&gt;
  &lt;!-- 1. Suppress EF Core query spam (Debug/Info) --&gt;
  &lt;logger name=&quot;Microsoft.EntityFrameworkCore.Database.Command&quot;
          maxlevel=&quot;Info&quot; final=&quot;true&quot; /&gt;

  &lt;!-- 2. Suppress all Microsoft.*/System.* Debug and Info --&gt;
  &lt;logger name=&quot;Microsoft.*&quot; maxlevel=&quot;Info&quot; final=&quot;true&quot; /&gt;
  &lt;logger name=&quot;System.*&quot; maxlevel=&quot;Info&quot; final=&quot;true&quot; /&gt;

  &lt;!-- 3. Microsoft.* Warn+ → file only (no console) --&gt;
  &lt;logger name=&quot;Microsoft.*&quot; minlevel=&quot;Warn&quot; writeTo=&quot;file&quot; final=&quot;true&quot; /&gt;
  &lt;logger name=&quot;System.*&quot; minlevel=&quot;Warn&quot; writeTo=&quot;file&quot; final=&quot;true&quot; /&gt;

  &lt;!-- 4. Application errors → dedicated error file (in addition to rules below) --&gt;
  &lt;logger name=&quot;MyApp.*&quot; minlevel=&quot;Error&quot; writeTo=&quot;errorFile&quot; /&gt;

  &lt;!-- 5. All application logs → console + rolling file --&gt;
  &lt;logger name=&quot;*&quot; minlevel=&quot;Info&quot; writeTo=&quot;console,file&quot; /&gt;
&lt;/rules&gt;
</code></pre>
</div>
<p>This layered approach eliminates framework noise from the console, keeps framework warnings in the file for postmortem analysis, and maintains a separate error file for fast filtering during incidents. NLog's target system -- file, console, database, Seq, and others -- is what backs the <code>writeTo</code> attribute in each rule.</p>
<h2 id="frequently-asked-questions">Frequently Asked Questions</h2>
<h3 id="what-does-finaltrue-do-in-nlog-rules">What does <code>final=&quot;true&quot;</code> do in NLog rules?</h3>
<p><code>final=&quot;true&quot;</code> stops NLog from evaluating further rules for the current log event once the rule with <code>final</code> matches. Without it, NLog continues evaluating all remaining rules and sends the event to any additional matching targets. Use <code>final</code> when you want route-and-stop behavior -- the event is handled by exactly one rule and goes no further.</p>
<h3 id="how-do-i-silence-microsoft.logs-without-affecting-my-application-logs">How do I silence Microsoft.* logs without affecting my application logs?</h3>
<p>Add two rules before your catch-all: one that matches <code>Microsoft.*</code> with <code>maxlevel=&quot;Info&quot;</code> and <code>final=&quot;true&quot;</code> (no <code>writeTo</code> -- this discards Debug/Info silently), and one that matches <code>Microsoft.*</code> with <code>minlevel=&quot;Warn&quot;</code> and routes to a file target with <code>final=&quot;true&quot;</code>. This sends framework warnings to file without showing them on the console, and completely drops Debug/Info noise.</p>
<h3 id="can-a-single-log-event-match-multiple-nlog-rules">Can a single log event match multiple NLog rules?</h3>
<p>Yes -- by default, NLog evaluates all rules top-to-bottom and a single event can match and be written by multiple rules simultaneously. This is how you fan out to multiple targets. The only exception is when a matching rule has <code>final=&quot;true&quot;</code>, which stops evaluation after that rule.</p>
<h3 id="what-is-the-difference-between-level-and-minlevel-in-nlog-rules">What is the difference between <code>level</code> and <code>minlevel</code> in NLog rules?</h3>
<p><code>level</code> matches exactly one log level (e.g., <code>level=&quot;Error&quot;</code> matches only Error, not Fatal). <code>minlevel</code> matches that level and all higher levels (e.g., <code>minlevel=&quot;Warn&quot;</code> matches Warn, Error, and Fatal). <code>maxlevel</code> matches that level and all lower levels. Use <code>levels=&quot;Error,Fatal&quot;</code> when you need a non-contiguous set.</p>
<h3 id="how-do-i-use-when-filters-to-ignore-health-check-log-events">How do I use <code>when</code> filters to ignore health check log events?</h3>
<p>Add a <code>&lt;filters&gt;</code> block inside the rule and use a <code>when</code> condition that matches the URL or message content. For ASP.NET Core health checks, <code>contains('${aspnet-request-url}', '/health')</code> with <code>action=&quot;Ignore&quot;</code> drops events from health check endpoints before they are written to any target. Install <code>NLog.Web.AspNetCore</code> to enable the <code>aspnet-request-url</code> renderer.</p>
<h3 id="how-do-i-route-logs-to-different-files-by-log-level">How do I route logs to different files by log level?</h3>
<p>Use multiple non-final rules, each with <code>writeTo</code> pointing to a different target, and level constraints that don't overlap. For example: a rule with <code>minlevel=&quot;Error&quot;</code> and <code>writeTo=&quot;errorFile&quot;</code>, and a separate rule with <code>minlevel=&quot;Info&quot; maxlevel=&quot;Warn&quot;</code> and <code>writeTo=&quot;infoFile&quot;</code>. Since neither rule uses <code>final</code>, Error events match the first rule only (because Errors are above maxlevel=Warn), while Warn and Info events match only the second rule.</p>
<h3 id="should-i-configure-nlog-rules-in-xml-or-json">Should I configure NLog rules in XML or JSON?</h3>
<p>Both are functionally equivalent. Use JSON (<code>appsettings.json</code>) for ASP.NET Core applications where you want environment-specific overrides via <code>appsettings.Production.json</code> or environment variables. Use XML (<code>nlog.config</code>) when your operations team is more comfortable with it or when you need NLog-specific features like dynamic reloading via <code>autoReload=&quot;true&quot;</code>. In ASP.NET Core specifically, integrating NLog rules with the host's middleware pipeline -- for instance, to enrich log context per-request -- works cleanly alongside <a href="https://www.devleader.ca/2024/01/31/custom-middleware-in-aspnet-core-how-to-harness-the-power">custom middleware</a>.</p>
<h2 id="wrapping-up">Wrapping Up</h2>
<p>NLog's rules and filters system is one of its biggest strengths over simpler logging libraries. The combination of glob name matching, level ranges, <code>final=&quot;true&quot;</code> short-circuits, and expression-based <code>when</code> filters lets you implement arbitrarily complex routing logic without any C# code -- just configuration.</p>
<p>The key patterns to internalize:</p>
<ul>
<li><strong>Silence by namespace</strong>: <code>name=&quot;Microsoft.*&quot; maxlevel=&quot;Info&quot; final=&quot;true&quot;</code> (no writeTo)</li>
<li><strong>Route and stop</strong>: match a category, send it to a target, add <code>final=&quot;true&quot;</code></li>
<li><strong>Fan-out</strong>: multiple non-final rules all matching <code>name=&quot;*&quot;</code> send to multiple targets simultaneously</li>
<li><strong>Condition filtering</strong>: <code>when</code> conditions inside rules for per-event decisions</li>
</ul>
<p>Complex rule chains have measurable performance implications -- especially when rules fan out to multiple slow targets synchronously. Wrapping synchronous targets in an <code>AsyncWrapper</code> and benchmarking with <a href="https://www.devleader.ca/2024/03/05/how-to-use-benchmarkdotnet-6-simple-performance-boosting-tips-to-get-started">BenchmarkDotNet</a> are the key techniques for keeping NLog from becoming a throughput bottleneck in high-traffic applications. For comparison on how Serilog approaches similar routing decisions, the <a href="https://www.devleader.ca/2026/07/05/serilog-in-net-complete-guide-to-structured-logging">Serilog in .NET complete guide</a> covers the equivalent sink and filter configuration patterns.</p>
]]></content:encoded>
      <media:content url="https://devleader-d2f9ggbjfpdqcka7.z01.azurefd.net/media/nlog-rules-and-filters-routing-logs-dotnet.webp" />
    </item>
    <item>
      <guid isPermaLink="false">07615edc-f825-48a3-a6b9-b97b7b947e5b</guid>
      <link>https://www.devleader.ca/2026/08/09/what-comes-after-senior-software-engineer-dev-leader-weekly-152</link>
      <category>Dev Leader Weekly</category>
      <category>Dev Leader</category>
      <category>Software Engineering Newsletter</category>
      <category>Newsletter</category>
      <title>What Comes After Senior Software Engineer? - Dev Leader Weekly 152</title>
      <pubDate>Sun, 09 Aug 2026 20:46:25 Z</pubDate>
      <description><![CDATA[<h2 id="tl-dr">TL; DR:</h2>
<ul>
<li>Senior is a successful career destination</li>
<li>Higher levels require broader organizational scope</li>
<li>Career progression is not a straight line</li>
<li><a href="https://youtube.com/live/NYYq9yVMJfE?feature=share">Join me for the live stream (or watch the recording) on Monday, August 10 at 7:00 PM Pacific</a>!</li>
</ul>
<div style="text-align: center;">
    <iframe src="https://www.youtube.com/embed/NYYq9yVMJfE" width="600" height="400" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen loading="lazy"></iframe>
</div>
<hr />
<h2 id="what-comes-after-senior-software-engineer">What Comes After Senior Software Engineer?</h2>
<p>Have you ever looked at a software engineering career ladder and wondered why everything seems to move reasonably well until senior, and then suddenly the next step feels like a wall?</p>
<p>You're not imagining that shift.</p>
<p>I have talked before about <a href="https://www.devleader.ca/2025/07/05/why-youre-stuck-at-senior-software-engineer-dev-leader-weekly-101?utm_source=devleader_weekly&amp;utm_medium=newsletter&amp;utm_campaign=dlw-152">why engineers can feel stuck at senior</a>, but I want to approach this from a slightly different angle. Sometimes the friction is about your growth, your manager, or unclear expectations. But sometimes the ladder itself genuinely changes shape after senior.</p>
<p>That does <strong>not</strong> mean you failed. It does not mean your company wants to hold you back. And despite how easy it is to blame every weird career problem on AI right now, I do not think AI created this one.</p>
<p>So what is actually going on?</p>
<p>You can <a href="https://www.youtube.com/watch?v=faV_vitlEbg">check out my full thoughts on this in the video</a> below:</p>
<div style="text-align: center;">
    <iframe src="https://www.youtube.com/embed/faV_vitlEbg" width="600" height="400" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen loading="lazy"></iframe>
</div>
<h2 id="the-ladder-changes-shape-after-senior">The Ladder Changes Shape After Senior</h2>
<p>The first complication is that software engineering titles are wildly inconsistent.</p>
<p>Junior, mid-level, and senior are common concepts, even if a company calls them Software Engineer I, Software Engineer II, SDE, SWE, or something else. Beyond senior? Things get messy fast.</p>
<p>One company might have staff, principal, senior principal, distinguished, fellow, or partner roles. Another company might skip staff entirely. A smaller company might have nothing beyond senior on the individual contributor track.</p>
<p>That was my experience before Microsoft. For most of my eight years there, the technical path was basically developer and senior developer. There was an architect role that never felt fully defined. Even as a technical manager, I eventually reached a point where there was no clearly established next role on that track.</p>
<p>Was I suddenly incapable of growing? Of course not. <strong>The organization simply had not created an endless sequence of titles.</strong></p>
<p>That distinction matters. We often treat a career ladder like a universal map, but it is really a model of what one organization needs.</p>
<h2 id="title-inflation-makes-the-wall-feel-worse">Title Inflation Makes The Wall Feel Worse</h2>
<p>I want to be careful here because I do not have data to prove this. Treat it as an observation from roughly fifteen years in the industry, not a measured fact.</p>
<p>I suspect the expected time between early-career promotions has compressed. Companies use titles as recruiting tools. Employees move between organizations more often. Managers feel pressure to show progression. People understandably compare their timelines with everyone announcing promotions online.</p>
<p>The result is an expectation that every step should arrive on roughly the same schedule -- or at least that there should be very concrete steps in a prescribed amount of time to get there.</p>
<p>But careers are not linear. Going from level one to level two in a year does not mean every future level should take one more year. Follow that math far enough and everyone becomes a CTO before the end of the decade. Clearly, that is not how organizations work.</p>
<p>The higher you go, the larger the expected scope usually becomes. There are fewer roles with that scope, fewer opportunities to demonstrate it, and fewer people the organization actually needs operating there.</p>
<p>That curve was always going to get steeper. Title inflation just makes the change feel more abrupt.</p>
<h2 id="senior-can-be-the-destination">Senior Can Be The Destination</h2>
<p>The phrase people often use is <strong>terminal level</strong>. I don't love it. It sounds dark, like your career has arrived at a dead end.</p>
<p>That's not what it means.</p>
<p>A terminal level is a stable point where someone can perform successfully for the rest of their career. For many software engineering organizations, that point is somewhere in the senior band.</p>
<p>Think about what that says about you. You are trusted to solve difficult problems. You can deliver high-quality work consistently. Teams depend on you. You can guide other engineers, handle ambiguity, and operate without someone spelling out every step.</p>
<p>That <em><strong>is</strong></em> a successful career.</p>
<p>It can also be a demanding one. I have written about how <a href="https://www.devleader.ca/2025/12/06/senior-engineers-spend-less-time-coding-dev-leader-weekly-118?utm_source=devleader_weekly&amp;utm_medium=newsletter&amp;utm_campaign=dlw-152">senior engineers often spend less time coding</a> because their impact increasingly includes design, mentoring, coordination, and helping others move faster. Staying at senior does not mean staying static.</p>
<p>Your skills can deepen. Your influence can grow. Your compensation can improve. You can take on new domains and become the person everyone relies on without collecting another title every eighteen months.</p>
<h2 id="compensation-and-promotion-are-related-not-identical">Compensation And Promotion Are Related, Not Identical</h2>
<p>This is usually where someone asks: &quot;Okay, but what happens to my compensation if I am not getting promoted?&quot;</p>
<p>Fair question.</p>
<p>The exact answer depends on the company, but promotion should not be the only mechanism for recognizing strong performance. Salary growth, bonuses, stock, expanded ownership, and other rewards can continue while you remain in the same level.</p>
<p>A promotion usually comes with a larger adjustment because the expectations changed. But that does not mean every strong review must end with a new title.</p>
<p>You can be performing extremely well at senior, receive above-target rewards, and still not be operating at the next level. Those signals overlap, but they are not identical.</p>
<p>That can actually be a healthy balance. Maybe you are kicking ass at senior, being paid fairly, doing meaningful work, and preserving room for your family or everything else outside work. There is nothing lesser about that choice.</p>
<h2 id="organizations-cannot-be-made-entirely-of-principal-engineers">Organizations Cannot Be Made Entirely Of Principal Engineers</h2>
<p>Now we get to the structural part.</p>
<p>Imagine an organization where every engineer is principal, staff, distinguished, or whatever the top levels are called. It sounds amazing if you picture a video game team where everyone has max stats. If you could build an NBA 2K team of max-stat players, or your RPG party was all max level, that's what you'd want -- right?</p>
<p>In practice, what work are all of those people doing?</p>
<p>The next level after senior usually expects broader impact across teams, products, or an organization. If you are actively targeting that path, I have a separate breakdown of <a href="https://www.devleader.ca/2026/07/11/what-it-actually-takes-to-become-a-principal-engineer-dev-leader-weekly-148?utm_source=devleader_weekly&amp;utm_medium=newsletter&amp;utm_campaign=dlw-152">what principal-level scope actually looks like</a>. The important point here is that cross-organization work is not infinite -- at least not infinitely done in parallel with all the other cross-organization work.</p>
<p>A company needs people setting technical direction across teams. It also needs many more people turning that direction into reliable products, systems, and customer outcomes.</p>
<p>You do not need hundreds of engineers independently trying to lead company-wide initiatives. At some point, everyone collides with everyone else.</p>
<p>This is not an argument for gatekeeping. It is an explanation of why the number of roles naturally narrows. <strong>The organization needs a different distribution of scope at each level.</strong></p>
<h2 id="healthy-teams-need-a-spread-of-experience">Healthy Teams Need A Spread Of Experience</h2>
<p>The same idea applies within a team.</p>
<p>You do not want a team made entirely of brand-new engineers because the coaching and operational overhead would overwhelm the few experienced people supporting them. But stacking a team entirely with the highest-level engineers does not make sense either.</p>
<p>A healthy team usually has a spread:</p>
<ul>
<li>Junior engineers building fundamentals and taking on well-scoped work</li>
<li>Mid-level engineers carrying a large share of independent delivery</li>
<li>Senior engineers handling complex work, mentoring, and creating leverage</li>
<li>A smaller number of staff or principal engineers working across broader boundaries</li>
</ul>
<p>That spread creates continuity. Junior engineers learn from people with more experience. Senior engineers develop coaching and delegation skills. The organization builds its next generation instead of hoping experienced hires appear forever.</p>
<p>This is where AI enters the conversation, but not as the cause of the senior-level wall. Companies deciding they no longer need junior engineers because AI can replace them are creating a different problem. I have shared my concerns about <a href="https://www.devleader.ca/2025/06/07/are-junior-developers-in-big-trouble-with-ai-usage-dev-leader-weekly-97?utm_source=devleader_weekly&amp;utm_medium=newsletter&amp;utm_campaign=dlw-152">junior developers relying on AI without building fundamentals</a>, and cutting off the junior pipeline removes mentoring opportunities and future senior talent too.</p>
<p>The whole system needs continuity. You cannot keep only the top of the pyramid and expect it to replenish itself.</p>
<h2 id="what-to-do-when-you-feel-stuck">What To Do When You Feel Stuck</h2>
<p>Understanding the structure does not make vague promotion feedback any less frustrating.</p>
<p>If your manager keeps telling you, &quot;Maybe next time,&quot; while you spend another review cycle trying to prove yourself, you need more than encouragement. You need clarity.</p>
<p><em><strong>Actionable Tip</strong></em>: ask questions that separate your performance from the organization's available opportunities:</p>
<ol>
<li><strong>What specific evidence is missing?</strong> Ask for observable examples, not &quot;more impact.&quot;</li>
<li><strong>Am I consistently operating at the next level?</strong> A strong year at your current level is not automatically the same thing.</li>
<li><strong>Does this team have work with the required scope?</strong> You cannot demonstrate cross-team leadership if every meaningful decision is already owned elsewhere.</li>
<li><strong>Is a role realistically available?</strong> Sometimes the organizational shape creates more back pressure than anyone wants to say directly.</li>
<li><strong>What would make the next review materially different?</strong> If nobody can answer, that is useful information.</li>
</ol>
<p>You may discover that you have real gaps to close. Great. Now you can work on something concrete.</p>
<p>You may discover that you are ready, but the organization has no room. That does not automatically mean you should leave, but it gives you an honest tradeoff to consider. An internal move or another company may offer the scope that your current team cannot.</p>
<p>And please protect your energy. There is a difference between stretching into bigger responsibility and running yourself into the ground chasing a label. My advice on <a href="https://www.devleader.ca/2025/10/11/promotions-without-burnout-dev-leader-weekly-112?utm_source=devleader_weekly&amp;utm_medium=newsletter&amp;utm_campaign=dlw-152">pursuing promotions without burning yourself out</a> still applies here.</p>
<h2 id="your-title-is-not-your-entire-career">Your Title Is Not Your Entire Career</h2>
<p>I understand why titles matter. They affect compensation, opportunity, how recruiters find you, and how many of us measure whether we are moving forward.</p>
<p>But the ladder cannot be the only scoreboard.</p>
<p>Senior software engineer is not a waiting room where you sit until your real career begins. It is a high-skill, high-trust role that many people work very hard to reach. If you stay there and continue doing excellent work, that is not failure.</p>
<p>If you want principal, staff, management, or another path, go after it with clear eyes. Learn what the role actually requires. Find out whether your organization has the right opportunities. Build the evidence. Decide whether the tradeoffs fit the life you want.</p>
<p><em><strong>Don't let the absence of another title erase everything you have already accomplished.</strong></em></p>
<p>Careers are not straight lines. Organizations are not infinite ladders. And success is much bigger than the word printed beside your name.</p>
<hr />
<ul>
<li>Join me and other software engineers in the  <a href="https://sidestack.io/devleader" target="_blank" rel="noopener" title="Dev Leader Discord Community">private Discord community</a>!</li>
<li><a href="https://www.youtube.com/@devleaderpathtotech?sub_confirmation=1" target="_blank" rel="noopener" title="Path To Tech">Resume reviews and interview guidance</a>!</li>
<li><a href="https://www.youtube.com/@devleaderpodcast?sub_confirmation=1" target="_blank" rel="noopener" title="The Dev Leader Podcast">Software engineering podcast and livestreams</a>!</li>
<li><a href="https://www.youtube.com/@CodeCommute?sub_confirmation=1" target="_blank" rel="noopener" title="Code Commute">My Code Commute vlogs are on YouTube</a>!</li>
<li><a href="https://www.youtube.com/@devleaderBTS?sub_confirmation=1" target="_blank" rel="noopener" title="Dev Leader: Behind The Screen">All of my weekly vlogs are on YouTube</a>!</li>
<li>Remember to check out <a href="https://www.devleader.ca/courses/" title="Courses">my courses</a>, including <a href="https://dometrain.com/bundle/from-zero-to-hero-csharp?ref=nick-cosentino" target="_blank" rel="noopener" title="C# Zero to Hero Bundle - Dometrain">this awesome discounted bundle for C# developers</a>:</li>
</ul>
<div style="text-align: center;">
    <figure class="wp-block-image aligncenter size-full is-resized">
        <a href="https://dometrain.com/bundle/from-zero-to-hero-csharp?ref=nick-cosentino" target="_blank" rel="noreferrer noopener">
            <img src="https://devleader-d2f9ggbjfpdqcka7.z01.azurefd.net/media/2024/04/courses-dometrain-C-bundle.webp" alt="C# From Zero to Hero - Dometrain Course" class="wp-image-6879" style="width:600px"/>
        </a>
        <figcaption class="wp-element-caption">
            <a href="https://dometrain.com/bundle/from-zero-to-hero-csharp/?ref=nick-cosentino" target="_blank" rel="noopener" title="C# Zero to Hero Bundle - Dometrain">Get this DISCOUNTED course bundle NOW!</a>
        </figcaption>
    </figure>
</div>
<hr />
<p>As always, thanks so much for your support! I hope you enjoyed this issue, and I'll see you next week.</p>
<p>​Nick "Dev Leader" Cosentino<br>​<a href="mailto:social@devleader.ca" target="_blank" rel="noreferrer noopener">social@devleader.ca</a>​<br>​<br>Socials:<br>– <a href="https://www.devleader.ca/" target="_blank" rel="noreferrer noopener">Blog</a>​<br>– <a href="https://www.youtube.com/@devleader?sub_confirmation=1" target="_blank" rel="noopener" title="Dev Leader on YouTube">Dev Leader YouTube</a>​<br>– <a href="https://www.linkedin.com/in/nickcosentino/" target="_blank" rel="noreferrer noopener">Follow on LinkedIn</a>​<br>– <a href="https://instagram.com/dev.leader" target="_blank" rel="noreferrer noopener">Dev Leader Instagram</a>​<br>​</p>
<p>P.S. If you enjoyed this newsletter, consider <a href="https://weekly.devleader.ca/">sharing it with your fellow developers</a>!</p>
]]></description>
      <content:encoded><![CDATA[<h2 id="tl-dr">TL; DR:</h2>
<ul>
<li>Senior is a successful career destination</li>
<li>Higher levels require broader organizational scope</li>
<li>Career progression is not a straight line</li>
<li><a href="https://youtube.com/live/NYYq9yVMJfE?feature=share">Join me for the live stream (or watch the recording) on Monday, August 10 at 7:00 PM Pacific</a>!</li>
</ul>
<div style="text-align: center;">
    <iframe src="https://www.youtube.com/embed/NYYq9yVMJfE" width="600" height="400" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen loading="lazy"></iframe>
</div>
<hr />
<h2 id="what-comes-after-senior-software-engineer">What Comes After Senior Software Engineer?</h2>
<p>Have you ever looked at a software engineering career ladder and wondered why everything seems to move reasonably well until senior, and then suddenly the next step feels like a wall?</p>
<p>You're not imagining that shift.</p>
<p>I have talked before about <a href="https://www.devleader.ca/2025/07/05/why-youre-stuck-at-senior-software-engineer-dev-leader-weekly-101?utm_source=devleader_weekly&amp;utm_medium=newsletter&amp;utm_campaign=dlw-152">why engineers can feel stuck at senior</a>, but I want to approach this from a slightly different angle. Sometimes the friction is about your growth, your manager, or unclear expectations. But sometimes the ladder itself genuinely changes shape after senior.</p>
<p>That does <strong>not</strong> mean you failed. It does not mean your company wants to hold you back. And despite how easy it is to blame every weird career problem on AI right now, I do not think AI created this one.</p>
<p>So what is actually going on?</p>
<p>You can <a href="https://www.youtube.com/watch?v=faV_vitlEbg">check out my full thoughts on this in the video</a> below:</p>
<div style="text-align: center;">
    <iframe src="https://www.youtube.com/embed/faV_vitlEbg" width="600" height="400" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen loading="lazy"></iframe>
</div>
<h2 id="the-ladder-changes-shape-after-senior">The Ladder Changes Shape After Senior</h2>
<p>The first complication is that software engineering titles are wildly inconsistent.</p>
<p>Junior, mid-level, and senior are common concepts, even if a company calls them Software Engineer I, Software Engineer II, SDE, SWE, or something else. Beyond senior? Things get messy fast.</p>
<p>One company might have staff, principal, senior principal, distinguished, fellow, or partner roles. Another company might skip staff entirely. A smaller company might have nothing beyond senior on the individual contributor track.</p>
<p>That was my experience before Microsoft. For most of my eight years there, the technical path was basically developer and senior developer. There was an architect role that never felt fully defined. Even as a technical manager, I eventually reached a point where there was no clearly established next role on that track.</p>
<p>Was I suddenly incapable of growing? Of course not. <strong>The organization simply had not created an endless sequence of titles.</strong></p>
<p>That distinction matters. We often treat a career ladder like a universal map, but it is really a model of what one organization needs.</p>
<h2 id="title-inflation-makes-the-wall-feel-worse">Title Inflation Makes The Wall Feel Worse</h2>
<p>I want to be careful here because I do not have data to prove this. Treat it as an observation from roughly fifteen years in the industry, not a measured fact.</p>
<p>I suspect the expected time between early-career promotions has compressed. Companies use titles as recruiting tools. Employees move between organizations more often. Managers feel pressure to show progression. People understandably compare their timelines with everyone announcing promotions online.</p>
<p>The result is an expectation that every step should arrive on roughly the same schedule -- or at least that there should be very concrete steps in a prescribed amount of time to get there.</p>
<p>But careers are not linear. Going from level one to level two in a year does not mean every future level should take one more year. Follow that math far enough and everyone becomes a CTO before the end of the decade. Clearly, that is not how organizations work.</p>
<p>The higher you go, the larger the expected scope usually becomes. There are fewer roles with that scope, fewer opportunities to demonstrate it, and fewer people the organization actually needs operating there.</p>
<p>That curve was always going to get steeper. Title inflation just makes the change feel more abrupt.</p>
<h2 id="senior-can-be-the-destination">Senior Can Be The Destination</h2>
<p>The phrase people often use is <strong>terminal level</strong>. I don't love it. It sounds dark, like your career has arrived at a dead end.</p>
<p>That's not what it means.</p>
<p>A terminal level is a stable point where someone can perform successfully for the rest of their career. For many software engineering organizations, that point is somewhere in the senior band.</p>
<p>Think about what that says about you. You are trusted to solve difficult problems. You can deliver high-quality work consistently. Teams depend on you. You can guide other engineers, handle ambiguity, and operate without someone spelling out every step.</p>
<p>That <em><strong>is</strong></em> a successful career.</p>
<p>It can also be a demanding one. I have written about how <a href="https://www.devleader.ca/2025/12/06/senior-engineers-spend-less-time-coding-dev-leader-weekly-118?utm_source=devleader_weekly&amp;utm_medium=newsletter&amp;utm_campaign=dlw-152">senior engineers often spend less time coding</a> because their impact increasingly includes design, mentoring, coordination, and helping others move faster. Staying at senior does not mean staying static.</p>
<p>Your skills can deepen. Your influence can grow. Your compensation can improve. You can take on new domains and become the person everyone relies on without collecting another title every eighteen months.</p>
<h2 id="compensation-and-promotion-are-related-not-identical">Compensation And Promotion Are Related, Not Identical</h2>
<p>This is usually where someone asks: &quot;Okay, but what happens to my compensation if I am not getting promoted?&quot;</p>
<p>Fair question.</p>
<p>The exact answer depends on the company, but promotion should not be the only mechanism for recognizing strong performance. Salary growth, bonuses, stock, expanded ownership, and other rewards can continue while you remain in the same level.</p>
<p>A promotion usually comes with a larger adjustment because the expectations changed. But that does not mean every strong review must end with a new title.</p>
<p>You can be performing extremely well at senior, receive above-target rewards, and still not be operating at the next level. Those signals overlap, but they are not identical.</p>
<p>That can actually be a healthy balance. Maybe you are kicking ass at senior, being paid fairly, doing meaningful work, and preserving room for your family or everything else outside work. There is nothing lesser about that choice.</p>
<h2 id="organizations-cannot-be-made-entirely-of-principal-engineers">Organizations Cannot Be Made Entirely Of Principal Engineers</h2>
<p>Now we get to the structural part.</p>
<p>Imagine an organization where every engineer is principal, staff, distinguished, or whatever the top levels are called. It sounds amazing if you picture a video game team where everyone has max stats. If you could build an NBA 2K team of max-stat players, or your RPG party was all max level, that's what you'd want -- right?</p>
<p>In practice, what work are all of those people doing?</p>
<p>The next level after senior usually expects broader impact across teams, products, or an organization. If you are actively targeting that path, I have a separate breakdown of <a href="https://www.devleader.ca/2026/07/11/what-it-actually-takes-to-become-a-principal-engineer-dev-leader-weekly-148?utm_source=devleader_weekly&amp;utm_medium=newsletter&amp;utm_campaign=dlw-152">what principal-level scope actually looks like</a>. The important point here is that cross-organization work is not infinite -- at least not infinitely done in parallel with all the other cross-organization work.</p>
<p>A company needs people setting technical direction across teams. It also needs many more people turning that direction into reliable products, systems, and customer outcomes.</p>
<p>You do not need hundreds of engineers independently trying to lead company-wide initiatives. At some point, everyone collides with everyone else.</p>
<p>This is not an argument for gatekeeping. It is an explanation of why the number of roles naturally narrows. <strong>The organization needs a different distribution of scope at each level.</strong></p>
<h2 id="healthy-teams-need-a-spread-of-experience">Healthy Teams Need A Spread Of Experience</h2>
<p>The same idea applies within a team.</p>
<p>You do not want a team made entirely of brand-new engineers because the coaching and operational overhead would overwhelm the few experienced people supporting them. But stacking a team entirely with the highest-level engineers does not make sense either.</p>
<p>A healthy team usually has a spread:</p>
<ul>
<li>Junior engineers building fundamentals and taking on well-scoped work</li>
<li>Mid-level engineers carrying a large share of independent delivery</li>
<li>Senior engineers handling complex work, mentoring, and creating leverage</li>
<li>A smaller number of staff or principal engineers working across broader boundaries</li>
</ul>
<p>That spread creates continuity. Junior engineers learn from people with more experience. Senior engineers develop coaching and delegation skills. The organization builds its next generation instead of hoping experienced hires appear forever.</p>
<p>This is where AI enters the conversation, but not as the cause of the senior-level wall. Companies deciding they no longer need junior engineers because AI can replace them are creating a different problem. I have shared my concerns about <a href="https://www.devleader.ca/2025/06/07/are-junior-developers-in-big-trouble-with-ai-usage-dev-leader-weekly-97?utm_source=devleader_weekly&amp;utm_medium=newsletter&amp;utm_campaign=dlw-152">junior developers relying on AI without building fundamentals</a>, and cutting off the junior pipeline removes mentoring opportunities and future senior talent too.</p>
<p>The whole system needs continuity. You cannot keep only the top of the pyramid and expect it to replenish itself.</p>
<h2 id="what-to-do-when-you-feel-stuck">What To Do When You Feel Stuck</h2>
<p>Understanding the structure does not make vague promotion feedback any less frustrating.</p>
<p>If your manager keeps telling you, &quot;Maybe next time,&quot; while you spend another review cycle trying to prove yourself, you need more than encouragement. You need clarity.</p>
<p><em><strong>Actionable Tip</strong></em>: ask questions that separate your performance from the organization's available opportunities:</p>
<ol>
<li><strong>What specific evidence is missing?</strong> Ask for observable examples, not &quot;more impact.&quot;</li>
<li><strong>Am I consistently operating at the next level?</strong> A strong year at your current level is not automatically the same thing.</li>
<li><strong>Does this team have work with the required scope?</strong> You cannot demonstrate cross-team leadership if every meaningful decision is already owned elsewhere.</li>
<li><strong>Is a role realistically available?</strong> Sometimes the organizational shape creates more back pressure than anyone wants to say directly.</li>
<li><strong>What would make the next review materially different?</strong> If nobody can answer, that is useful information.</li>
</ol>
<p>You may discover that you have real gaps to close. Great. Now you can work on something concrete.</p>
<p>You may discover that you are ready, but the organization has no room. That does not automatically mean you should leave, but it gives you an honest tradeoff to consider. An internal move or another company may offer the scope that your current team cannot.</p>
<p>And please protect your energy. There is a difference between stretching into bigger responsibility and running yourself into the ground chasing a label. My advice on <a href="https://www.devleader.ca/2025/10/11/promotions-without-burnout-dev-leader-weekly-112?utm_source=devleader_weekly&amp;utm_medium=newsletter&amp;utm_campaign=dlw-152">pursuing promotions without burning yourself out</a> still applies here.</p>
<h2 id="your-title-is-not-your-entire-career">Your Title Is Not Your Entire Career</h2>
<p>I understand why titles matter. They affect compensation, opportunity, how recruiters find you, and how many of us measure whether we are moving forward.</p>
<p>But the ladder cannot be the only scoreboard.</p>
<p>Senior software engineer is not a waiting room where you sit until your real career begins. It is a high-skill, high-trust role that many people work very hard to reach. If you stay there and continue doing excellent work, that is not failure.</p>
<p>If you want principal, staff, management, or another path, go after it with clear eyes. Learn what the role actually requires. Find out whether your organization has the right opportunities. Build the evidence. Decide whether the tradeoffs fit the life you want.</p>
<p><em><strong>Don't let the absence of another title erase everything you have already accomplished.</strong></em></p>
<p>Careers are not straight lines. Organizations are not infinite ladders. And success is much bigger than the word printed beside your name.</p>
<hr />
<ul>
<li>Join me and other software engineers in the  <a href="https://sidestack.io/devleader" target="_blank" rel="noopener" title="Dev Leader Discord Community">private Discord community</a>!</li>
<li><a href="https://www.youtube.com/@devleaderpathtotech?sub_confirmation=1" target="_blank" rel="noopener" title="Path To Tech">Resume reviews and interview guidance</a>!</li>
<li><a href="https://www.youtube.com/@devleaderpodcast?sub_confirmation=1" target="_blank" rel="noopener" title="The Dev Leader Podcast">Software engineering podcast and livestreams</a>!</li>
<li><a href="https://www.youtube.com/@CodeCommute?sub_confirmation=1" target="_blank" rel="noopener" title="Code Commute">My Code Commute vlogs are on YouTube</a>!</li>
<li><a href="https://www.youtube.com/@devleaderBTS?sub_confirmation=1" target="_blank" rel="noopener" title="Dev Leader: Behind The Screen">All of my weekly vlogs are on YouTube</a>!</li>
<li>Remember to check out <a href="https://www.devleader.ca/courses/" title="Courses">my courses</a>, including <a href="https://dometrain.com/bundle/from-zero-to-hero-csharp?ref=nick-cosentino" target="_blank" rel="noopener" title="C# Zero to Hero Bundle - Dometrain">this awesome discounted bundle for C# developers</a>:</li>
</ul>
<div style="text-align: center;">
    <figure class="wp-block-image aligncenter size-full is-resized">
        <a href="https://dometrain.com/bundle/from-zero-to-hero-csharp?ref=nick-cosentino" target="_blank" rel="noreferrer noopener">
            <img src="https://devleader-d2f9ggbjfpdqcka7.z01.azurefd.net/media/2024/04/courses-dometrain-C-bundle.webp" alt="C# From Zero to Hero - Dometrain Course" class="wp-image-6879" style="width:600px"/>
        </a>
        <figcaption class="wp-element-caption">
            <a href="https://dometrain.com/bundle/from-zero-to-hero-csharp/?ref=nick-cosentino" target="_blank" rel="noopener" title="C# Zero to Hero Bundle - Dometrain">Get this DISCOUNTED course bundle NOW!</a>
        </figcaption>
    </figure>
</div>
<hr />
<p>As always, thanks so much for your support! I hope you enjoyed this issue, and I'll see you next week.</p>
<p>​Nick "Dev Leader" Cosentino<br>​<a href="mailto:social@devleader.ca" target="_blank" rel="noreferrer noopener">social@devleader.ca</a>​<br>​<br>Socials:<br>– <a href="https://www.devleader.ca/" target="_blank" rel="noreferrer noopener">Blog</a>​<br>– <a href="https://www.youtube.com/@devleader?sub_confirmation=1" target="_blank" rel="noopener" title="Dev Leader on YouTube">Dev Leader YouTube</a>​<br>– <a href="https://www.linkedin.com/in/nickcosentino/" target="_blank" rel="noreferrer noopener">Follow on LinkedIn</a>​<br>– <a href="https://instagram.com/dev.leader" target="_blank" rel="noreferrer noopener">Dev Leader Instagram</a>​<br>​</p>
<p>P.S. If you enjoyed this newsletter, consider <a href="https://weekly.devleader.ca/">sharing it with your fellow developers</a>!</p>
]]></content:encoded>
      <media:content url="https://devleader-d2f9ggbjfpdqcka7.z01.azurefd.net/media/DLW-152.webp" />
    </item>
    <item>
      <guid isPermaLink="false">79c00b53-41b6-4eae-8b7e-1f488c5d108e</guid>
      <link>https://www.devleader.ca/2026/08/08/weekly-recap-nlog-in-net-opentelemetry-and-engineering-careers-aug-2026</link>
      <category>.net logging</category>
      <category>Articles</category>
      <category>Dev Leader</category>
      <category>distributed tracing .net opentelemetry</category>
      <category>Newsletter</category>
      <category>nlog .net</category>
      <category>nlog aspnet core</category>
      <category>nlog aspnet core .net 8</category>
      <category>nlog c#</category>
      <category>nlog configuration</category>
      <category>nlog custom target</category>
      <category>nlog database target</category>
      <category>nlog file target</category>
      <category>nlog getting started</category>
      <category>nlog guide</category>
      <category>nlog ilogger</category>
      <category>nlog jsonlayout</category>
      <category>nlog layout renderers</category>
      <category>nlog mdc</category>
      <category>nlog message template</category>
      <category>nlog setup</category>
      <category>nlog structured logging</category>
      <category>nlog targets</category>
      <category>nlog tutorial</category>
      <category>opentelemetry baggage</category>
      <category>opentelemetry distributed tracing c#</category>
      <category>Software Engineering Newsletter</category>
      <category>structured logging csharp</category>
      <category>traceparent header .net</category>
      <category>Videos</category>
      <category>w3c trace context dotnet</category>
      <category>Weekly Recap</category>
      <title>Weekly Recap: NLog in .NET, OpenTelemetry, and Engineering Careers [Aug 2026]</title>
      <pubDate>Sat, 08 Aug 2026 00:00:00 Z</pubDate>
      <description><![CDATA[<p><strong>This week:</strong> Take a practical tour through NLog in .NET, from ASP.NET Core setup and target configuration to layout renderers and structured logging. The recap also covers distributed tracing with OpenTelemetry, W3C Trace Context, and Baggage. On the career side, the videos tackle supporting an underperforming peer and deciding what comes after senior software engineer.</p>
<p>Like what you read or watched from the recap? I'd love if you helped share on <a href="https://reddit.com">Reddit</a> or <a href="https://app.daily.dev/squads/devleader">Daily.dev</a> so others can see!</p>
<hr />
<h2 id="weekly-recap">Weekly Recap</h2>
<h3 id="im-sick-of-helping-my-incompetent-engineering-peer"><a href="https://www.youtube.com/watch?v=RH5NkfMBT_U" target="_blank" rel="noopener" title="I'm Sick Of Helping My Incompetent Engineering Peer">I'm Sick Of Helping My Incompetent Engineering Peer</a></h3>
<div style="text-align: center;">
    <iframe src="https://www.youtube.com/embed/RH5NkfMBT_U" width="600" height="400" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen loading="lazy"></iframe>
</div>
<!-- wp:paragraph -->
<p> From the ExperiencedDevs subreddit, this developer wanted perspectives on working with a colleague that seems incompetent.</p>
<!-- /wp:paragraph -->
<h3 id="what-the-heck-comes-after-senior-software-engineer"><a href="https://www.youtube.com/watch?v=faV_vitlEbg" target="_blank" rel="noopener" title="What The HECK Comes After Senior Software Engineer?">What The HECK Comes After Senior Software Engineer?</a></h3>
<div style="text-align: center;">
    <iframe src="https://www.youtube.com/embed/faV_vitlEbg" width="600" height="400" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen loading="lazy"></iframe>
</div>
<!-- wp:paragraph -->
<p> From the ExperiencedDevs subreddit, this developer wanted perspectives on progressing beyond senior software engineer.</p>
<!-- /wp:paragraph -->
<h3 id="nlog-layout-renderers-and-structured-logging-in-c"><a href="https://www.devleader.ca/2026/08/07/nlog-layout-renderers-and-structured-logging-in-c?utm_source=weeklyrecap&utm_medium=email&utm_campaign=weekly-recap-2026-08-08" target="_blank" rel="noopener" title="NLog Layout Renderers and Structured Logging in C#">NLog Layout Renderers and Structured Logging in C#</a></h3>
<div style="text-align: center;">
    <figure class="wp-block-image aligncenter size-large">
        <a href="https://www.devleader.ca/2026/08/07/nlog-layout-renderers-and-structured-logging-in-c?utm_source=weeklyrecap&utm_medium=email&utm_campaign=weekly-recap-2026-08-08"><img src="https://devleader-d2f9ggbjfpdqcka7.z01.azurefd.net/media/nlog-layout-renderers-structured-logging-csharp.webp" alt="NLog Layout Renderers and Structured Logging in C#" style="width:600px"/></a>
    </figure>
</div>
<!-- wp:paragraph -->
<p> Master NLog layout renderers and structured logging in C#. Learn how to use JsonLayout, message templates, MDC correlation IDs, and custom layout renderers with real .NET examples.</p>
<!-- /wp:paragraph -->
<h3 id="nlog-targets-in.net-file-database-console-and-custom"><a href="https://www.devleader.ca/2026/08/05/nlog-targets-in-net-file-database-console-and-custom?utm_source=weeklyrecap&utm_medium=email&utm_campaign=weekly-recap-2026-08-08" target="_blank" rel="noopener" title="NLog Targets in .NET: File, Database, Console, and Custom">NLog Targets in .NET: File, Database, Console, and Custom</a></h3>
<div style="text-align: center;">
    <figure class="wp-block-image aligncenter size-large">
        <a href="https://www.devleader.ca/2026/08/05/nlog-targets-in-net-file-database-console-and-custom?utm_source=weeklyrecap&utm_medium=email&utm_campaign=weekly-recap-2026-08-08"><img src="https://devleader-d2f9ggbjfpdqcka7.z01.azurefd.net/media/nlog-targets-file-database-console-custom.webp" alt="NLog Targets in .NET: File, Database, Console, and Custom" style="width:600px"/></a>
    </figure>
</div>
<!-- wp:paragraph -->
<p> Deep dive into NLog targets in .NET. Learn how to configure File, Console, Database, Seq, and custom targets with real C# examples for ASP.NET Core and Worker Services.</p>
<!-- /wp:paragraph -->
<h3 id="getting-started-with-nlog-in-asp.net-core"><a href="https://www.devleader.ca/2026/08/03/getting-started-with-nlog-in-aspnet-core?utm_source=weeklyrecap&utm_medium=email&utm_campaign=weekly-recap-2026-08-08" target="_blank" rel="noopener" title="Getting Started with NLog in ASP.NET Core">Getting Started with NLog in ASP.NET Core</a></h3>
<div style="text-align: center;">
    <figure class="wp-block-image aligncenter size-large">
        <a href="https://www.devleader.ca/2026/08/03/getting-started-with-nlog-in-aspnet-core?utm_source=weeklyrecap&utm_medium=email&utm_campaign=weekly-recap-2026-08-08"><img src="https://devleader-d2f9ggbjfpdqcka7.z01.azurefd.net/media/getting-started-nlog-aspnet-core.webp" alt="Getting Started with NLog in ASP.NET Core" style="width:600px"/></a>
    </figure>
</div>
<!-- wp:paragraph -->
<p> Learn how to set up NLog in ASP.NET Core step by step. Covers XML nlog.config and appsettings.json configuration, Program.cs setup, Worker Services, and ILogger<T> usage.</p>
<!-- /wp:paragraph -->
<h3 id="nlog-in.net-complete-guide-to-flexible-logging"><a href="https://www.devleader.ca/2026/08/01/nlog-in-net-complete-guide-to-flexible-logging?utm_source=weeklyrecap&utm_medium=email&utm_campaign=weekly-recap-2026-08-08" target="_blank" rel="noopener" title="NLog in .NET: Complete Guide to Flexible Logging">NLog in .NET: Complete Guide to Flexible Logging</a></h3>
<div style="text-align: center;">
    <figure class="wp-block-image aligncenter size-large">
        <a href="https://www.devleader.ca/2026/08/01/nlog-in-net-complete-guide-to-flexible-logging?utm_source=weeklyrecap&utm_medium=email&utm_campaign=weekly-recap-2026-08-08"><img src="https://devleader-d2f9ggbjfpdqcka7.z01.azurefd.net/media/nlog-in-net-complete-guide.webp" alt="NLog in .NET: Complete Guide to Flexible Logging" style="width:600px"/></a>
    </figure>
</div>
<!-- wp:paragraph -->
<p> Master NLog in .NET with this complete guide. Learn targets, rules, layout renderers, structured logging, and performance optimization for C# applications.</p>
<!-- /wp:paragraph -->
<h3 id="distributed-tracing-across.net-services-with-opentelemetry-w3c-trace-context-and-baggage"><a href="https://www.devleader.ca/2026/08/01/distributed-tracing-across-net-services-with-opentelemetry-w3c-trace-context-and-baggage?utm_source=weeklyrecap&utm_medium=email&utm_campaign=weekly-recap-2026-08-08" target="_blank" rel="noopener" title="Distributed Tracing Across .NET Services with OpenTelemetry: W3C Trace Context and Baggage">Distributed Tracing Across .NET Services with OpenTelemetry: W3C Trace Context and Baggage</a></h3>
<div style="text-align: center;">
    <figure class="wp-block-image aligncenter size-large">
        <a href="https://www.devleader.ca/2026/08/01/distributed-tracing-across-net-services-with-opentelemetry-w3c-trace-context-and-baggage?utm_source=weeklyrecap&utm_medium=email&utm_campaign=weekly-recap-2026-08-08"><img src="https://devleader-d2f9ggbjfpdqcka7.z01.azurefd.net/media/distributed-tracing-dotnet-opentelemetry-w3c.webp" alt="Distributed Tracing Across .NET Services with OpenTelemetry: W3C Trace Context and Baggage" style="width:600px"/></a>
    </figure>
</div>
<!-- wp:paragraph -->
<p> Learn distributed tracing in .NET with OpenTelemetry: W3C trace context, Baggage, and traceparent headers linking spans across services, with C# examples.</p>
<!-- /wp:paragraph -->
<hr />
<ul>
<li>Join me and other software engineers in the  <a href="https://sidestack.io/devleader" target="_blank" rel="noopener" title="Dev Leader Discord Community">private Discord community</a>!</li>
<li><a href="https://www.youtube.com/@devleaderpathtotech?sub_confirmation=1" target="_blank" rel="noopener" title="Path To Tech">Resume reviews and interview guidance</a>!</li>
<li><a href="https://www.youtube.com/@devleaderpodcast?sub_confirmation=1" target="_blank" rel="noopener" title="The Dev Leader Podcast">Software engineering podcast and livestreams</a>!</li>
<li><a href="https://www.youtube.com/@CodeCommute?sub_confirmation=1" target="_blank" rel="noopener" title="Code Commute">My Code Commute vlogs are on YouTube</a>!</li>
<li><a href="https://www.youtube.com/@devleaderBTS?sub_confirmation=1" target="_blank" rel="noopener" title="Dev Leader: Behind The Screen">All of my weekly vlogs are on YouTube</a>!</li>
<li>Remember to check out <a href="https://www.devleader.ca/courses/" title="Courses">my courses</a>, including <a href="https://dometrain.com/bundle/from-zero-to-hero-csharp?ref=nick-cosentino" target="_blank" rel="noopener" title="C# Zero to Hero Bundle - Dometrain">this awesome discounted bundle for C# developers</a>:</li>
</ul>
<div style="text-align: center;">
    <figure class="wp-block-image aligncenter size-full is-resized">
        <a href="https://dometrain.com/bundle/from-zero-to-hero-csharp?ref=nick-cosentino" target="_blank" rel="noreferrer noopener">
            <img src="https://devleader-d2f9ggbjfpdqcka7.z01.azurefd.net/media/2024/04/courses-dometrain-C-bundle.webp" alt="C# From Zero to Hero - Dometrain Course" class="wp-image-6879" style="width:600px"/>
        </a>
        <figcaption class="wp-element-caption">
            <a href="https://dometrain.com/bundle/from-zero-to-hero-csharp/?ref=nick-cosentino" target="_blank" rel="noopener" title="C# Zero to Hero Bundle - Dometrain">Get this DISCOUNTED course bundle NOW!</a>
        </figcaption>
    </figure>
</div>
]]></description>
      <content:encoded><![CDATA[<p><strong>This week:</strong> Take a practical tour through NLog in .NET, from ASP.NET Core setup and target configuration to layout renderers and structured logging. The recap also covers distributed tracing with OpenTelemetry, W3C Trace Context, and Baggage. On the career side, the videos tackle supporting an underperforming peer and deciding what comes after senior software engineer.</p>
<p>Like what you read or watched from the recap? I'd love if you helped share on <a href="https://reddit.com">Reddit</a> or <a href="https://app.daily.dev/squads/devleader">Daily.dev</a> so others can see!</p>
<hr />
<h2 id="weekly-recap">Weekly Recap</h2>
<h3 id="im-sick-of-helping-my-incompetent-engineering-peer"><a href="https://www.youtube.com/watch?v=RH5NkfMBT_U" target="_blank" rel="noopener" title="I'm Sick Of Helping My Incompetent Engineering Peer">I'm Sick Of Helping My Incompetent Engineering Peer</a></h3>
<div style="text-align: center;">
    <iframe src="https://www.youtube.com/embed/RH5NkfMBT_U" width="600" height="400" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen loading="lazy"></iframe>
</div>
<!-- wp:paragraph -->
<p> From the ExperiencedDevs subreddit, this developer wanted perspectives on working with a colleague that seems incompetent.</p>
<!-- /wp:paragraph -->
<h3 id="what-the-heck-comes-after-senior-software-engineer"><a href="https://www.youtube.com/watch?v=faV_vitlEbg" target="_blank" rel="noopener" title="What The HECK Comes After Senior Software Engineer?">What The HECK Comes After Senior Software Engineer?</a></h3>
<div style="text-align: center;">
    <iframe src="https://www.youtube.com/embed/faV_vitlEbg" width="600" height="400" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen loading="lazy"></iframe>
</div>
<!-- wp:paragraph -->
<p> From the ExperiencedDevs subreddit, this developer wanted perspectives on progressing beyond senior software engineer.</p>
<!-- /wp:paragraph -->
<h3 id="nlog-layout-renderers-and-structured-logging-in-c"><a href="https://www.devleader.ca/2026/08/07/nlog-layout-renderers-and-structured-logging-in-c?utm_source=weeklyrecap&utm_medium=email&utm_campaign=weekly-recap-2026-08-08" target="_blank" rel="noopener" title="NLog Layout Renderers and Structured Logging in C#">NLog Layout Renderers and Structured Logging in C#</a></h3>
<div style="text-align: center;">
    <figure class="wp-block-image aligncenter size-large">
        <a href="https://www.devleader.ca/2026/08/07/nlog-layout-renderers-and-structured-logging-in-c?utm_source=weeklyrecap&utm_medium=email&utm_campaign=weekly-recap-2026-08-08"><img src="https://devleader-d2f9ggbjfpdqcka7.z01.azurefd.net/media/nlog-layout-renderers-structured-logging-csharp.webp" alt="NLog Layout Renderers and Structured Logging in C#" style="width:600px"/></a>
    </figure>
</div>
<!-- wp:paragraph -->
<p> Master NLog layout renderers and structured logging in C#. Learn how to use JsonLayout, message templates, MDC correlation IDs, and custom layout renderers with real .NET examples.</p>
<!-- /wp:paragraph -->
<h3 id="nlog-targets-in.net-file-database-console-and-custom"><a href="https://www.devleader.ca/2026/08/05/nlog-targets-in-net-file-database-console-and-custom?utm_source=weeklyrecap&utm_medium=email&utm_campaign=weekly-recap-2026-08-08" target="_blank" rel="noopener" title="NLog Targets in .NET: File, Database, Console, and Custom">NLog Targets in .NET: File, Database, Console, and Custom</a></h3>
<div style="text-align: center;">
    <figure class="wp-block-image aligncenter size-large">
        <a href="https://www.devleader.ca/2026/08/05/nlog-targets-in-net-file-database-console-and-custom?utm_source=weeklyrecap&utm_medium=email&utm_campaign=weekly-recap-2026-08-08"><img src="https://devleader-d2f9ggbjfpdqcka7.z01.azurefd.net/media/nlog-targets-file-database-console-custom.webp" alt="NLog Targets in .NET: File, Database, Console, and Custom" style="width:600px"/></a>
    </figure>
</div>
<!-- wp:paragraph -->
<p> Deep dive into NLog targets in .NET. Learn how to configure File, Console, Database, Seq, and custom targets with real C# examples for ASP.NET Core and Worker Services.</p>
<!-- /wp:paragraph -->
<h3 id="getting-started-with-nlog-in-asp.net-core"><a href="https://www.devleader.ca/2026/08/03/getting-started-with-nlog-in-aspnet-core?utm_source=weeklyrecap&utm_medium=email&utm_campaign=weekly-recap-2026-08-08" target="_blank" rel="noopener" title="Getting Started with NLog in ASP.NET Core">Getting Started with NLog in ASP.NET Core</a></h3>
<div style="text-align: center;">
    <figure class="wp-block-image aligncenter size-large">
        <a href="https://www.devleader.ca/2026/08/03/getting-started-with-nlog-in-aspnet-core?utm_source=weeklyrecap&utm_medium=email&utm_campaign=weekly-recap-2026-08-08"><img src="https://devleader-d2f9ggbjfpdqcka7.z01.azurefd.net/media/getting-started-nlog-aspnet-core.webp" alt="Getting Started with NLog in ASP.NET Core" style="width:600px"/></a>
    </figure>
</div>
<!-- wp:paragraph -->
<p> Learn how to set up NLog in ASP.NET Core step by step. Covers XML nlog.config and appsettings.json configuration, Program.cs setup, Worker Services, and ILogger<T> usage.</p>
<!-- /wp:paragraph -->
<h3 id="nlog-in.net-complete-guide-to-flexible-logging"><a href="https://www.devleader.ca/2026/08/01/nlog-in-net-complete-guide-to-flexible-logging?utm_source=weeklyrecap&utm_medium=email&utm_campaign=weekly-recap-2026-08-08" target="_blank" rel="noopener" title="NLog in .NET: Complete Guide to Flexible Logging">NLog in .NET: Complete Guide to Flexible Logging</a></h3>
<div style="text-align: center;">
    <figure class="wp-block-image aligncenter size-large">
        <a href="https://www.devleader.ca/2026/08/01/nlog-in-net-complete-guide-to-flexible-logging?utm_source=weeklyrecap&utm_medium=email&utm_campaign=weekly-recap-2026-08-08"><img src="https://devleader-d2f9ggbjfpdqcka7.z01.azurefd.net/media/nlog-in-net-complete-guide.webp" alt="NLog in .NET: Complete Guide to Flexible Logging" style="width:600px"/></a>
    </figure>
</div>
<!-- wp:paragraph -->
<p> Master NLog in .NET with this complete guide. Learn targets, rules, layout renderers, structured logging, and performance optimization for C# applications.</p>
<!-- /wp:paragraph -->
<h3 id="distributed-tracing-across.net-services-with-opentelemetry-w3c-trace-context-and-baggage"><a href="https://www.devleader.ca/2026/08/01/distributed-tracing-across-net-services-with-opentelemetry-w3c-trace-context-and-baggage?utm_source=weeklyrecap&utm_medium=email&utm_campaign=weekly-recap-2026-08-08" target="_blank" rel="noopener" title="Distributed Tracing Across .NET Services with OpenTelemetry: W3C Trace Context and Baggage">Distributed Tracing Across .NET Services with OpenTelemetry: W3C Trace Context and Baggage</a></h3>
<div style="text-align: center;">
    <figure class="wp-block-image aligncenter size-large">
        <a href="https://www.devleader.ca/2026/08/01/distributed-tracing-across-net-services-with-opentelemetry-w3c-trace-context-and-baggage?utm_source=weeklyrecap&utm_medium=email&utm_campaign=weekly-recap-2026-08-08"><img src="https://devleader-d2f9ggbjfpdqcka7.z01.azurefd.net/media/distributed-tracing-dotnet-opentelemetry-w3c.webp" alt="Distributed Tracing Across .NET Services with OpenTelemetry: W3C Trace Context and Baggage" style="width:600px"/></a>
    </figure>
</div>
<!-- wp:paragraph -->
<p> Learn distributed tracing in .NET with OpenTelemetry: W3C trace context, Baggage, and traceparent headers linking spans across services, with C# examples.</p>
<!-- /wp:paragraph -->
<hr />
<ul>
<li>Join me and other software engineers in the  <a href="https://sidestack.io/devleader" target="_blank" rel="noopener" title="Dev Leader Discord Community">private Discord community</a>!</li>
<li><a href="https://www.youtube.com/@devleaderpathtotech?sub_confirmation=1" target="_blank" rel="noopener" title="Path To Tech">Resume reviews and interview guidance</a>!</li>
<li><a href="https://www.youtube.com/@devleaderpodcast?sub_confirmation=1" target="_blank" rel="noopener" title="The Dev Leader Podcast">Software engineering podcast and livestreams</a>!</li>
<li><a href="https://www.youtube.com/@CodeCommute?sub_confirmation=1" target="_blank" rel="noopener" title="Code Commute">My Code Commute vlogs are on YouTube</a>!</li>
<li><a href="https://www.youtube.com/@devleaderBTS?sub_confirmation=1" target="_blank" rel="noopener" title="Dev Leader: Behind The Screen">All of my weekly vlogs are on YouTube</a>!</li>
<li>Remember to check out <a href="https://www.devleader.ca/courses/" title="Courses">my courses</a>, including <a href="https://dometrain.com/bundle/from-zero-to-hero-csharp?ref=nick-cosentino" target="_blank" rel="noopener" title="C# Zero to Hero Bundle - Dometrain">this awesome discounted bundle for C# developers</a>:</li>
</ul>
<div style="text-align: center;">
    <figure class="wp-block-image aligncenter size-full is-resized">
        <a href="https://dometrain.com/bundle/from-zero-to-hero-csharp?ref=nick-cosentino" target="_blank" rel="noreferrer noopener">
            <img src="https://devleader-d2f9ggbjfpdqcka7.z01.azurefd.net/media/2024/04/courses-dometrain-C-bundle.webp" alt="C# From Zero to Hero - Dometrain Course" class="wp-image-6879" style="width:600px"/>
        </a>
        <figcaption class="wp-element-caption">
            <a href="https://dometrain.com/bundle/from-zero-to-hero-csharp/?ref=nick-cosentino" target="_blank" rel="noopener" title="C# Zero to Hero Bundle - Dometrain">Get this DISCOUNTED course bundle NOW!</a>
        </figcaption>
    </figure>
</div>
]]></content:encoded>
      <media:content url="https://devleader-d2f9ggbjfpdqcka7.z01.azurefd.net/media/SquareBanner.webp" />
    </item>
  </channel>
</rss>