Back to blog

Engineering

Change Log Levels At Runtime In .NET, Down To A Single Tenant

Jeff Dwyer·July 26, 2026

In 2019 someone opened an issue on the ASP.NET Core docs called Update log level at runtime without app restart?. David Fowler answered it. The answer was, essentially: there isn't a logging API for this. You can get the behavior through a reloading configuration provider, but nothing in Microsoft.Extensions.Logging exposes "change the level now."

Seven years later that's still true, which is why the same question keeps getting re-asked in slightly different words: dynamically control the logging level of Serilog at runtime, changing log level at runtime, Feature Request: Add LogLevelOverride during runtime, How to increase NLog log level at runtime, Serilog, Change the loglevel at runtime for a specific namespace.

This post covers what actually works, the bug that makes it look like it doesn't work, and the thing none of it gets you — per-tenant levels.

The built-in answer: reloading configuration

Microsoft.Extensions.Logging reads its levels from IConfiguration, and the JSON provider watches the file. So this genuinely does work without a restart:

// appsettings.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Acme.Billing": "Warning",
      "Microsoft.EntityFrameworkCore.Database.Command": "Warning"
    }
  }
}

Edit the file, save it, and ILogger<T>.IsEnabled starts answering differently within a second or two. No API call, no restart. In Kubernetes, mount it from a ConfigMap and you get a fleet-wide change from a kubectl apply — the kubelet propagates the update and every pod's file watcher picks it up.

It's a real answer and for a lot of teams it's the end of the story. The catches:

  • ConfigMap propagation is eventually consistent and slow — up to a minute or so depending on your kubelet sync period, and it isn't uniform across pods. You'll have some instances at DEBUG and some at INFO for a while.
  • If your config lives in the container image or in environment variables rather than a mounted file, none of this applies and you're back to redeploying.
  • It's still fleet-wide, all-or-nothing.

Serilog: LoggingLevelSwitch

Serilog's answer is a mutable level holder you construct up front and keep a reference to:

using Serilog;
using Serilog.Core;
using Serilog.Events;
 
var levelSwitch = new LoggingLevelSwitch(LogEventLevel.Information);
 
Log.Logger = new LoggerConfiguration()
    .MinimumLevel.ControlledBy(levelSwitch)
    .WriteTo.Console()
    .CreateLogger();
 
// anywhere, any time, no rebuild:
levelSwitch.MinimumLevel = LogEventLevel.Debug;

Per-namespace works the same way, with one switch each:

var efSwitch = new LoggingLevelSwitch(LogEventLevel.Warning);
 
Log.Logger = new LoggerConfiguration()
    .MinimumLevel.ControlledBy(levelSwitch)
    .MinimumLevel.Override("Microsoft.EntityFrameworkCore", efSwitch)
    .WriteTo.Console()
    .CreateLogger();

LoggingLevelSwitch is cheap to check and safe to mutate concurrently. This is the correct primitive and it works.

The bug you are probably about to hit

Two issues on serilog-settings-configuration will feel familiar if you've tried this: Control level switch dynamically and LoggingLevelSwitch Not Switching at the Run Time. Different code, same reported symptom: the level appears to change and the logs don't.

There's one root cause behind almost every instance of this:

The switch object your logger is reading is not the switch object you're mutating.

It shows up in two flavors.

Flavor one — you replaced the switch instead of mutating it. This is what #376 turned out to be. The code did something like:

// WRONG — Serilog is still holding the old object
_switches[category] = new LoggingLevelSwitch(newLevel);

Serilog captured the original LoggingLevelSwitch when you called CreateLogger(). Putting a shiny new one in your dictionary changes nothing. There's exactly one correct move:

// RIGHT — mutate the instance Serilog already has
_switches[category].MinimumLevel = newLevel;

Flavor two — two instances exist. In #452 it came from a Lazy<T> static plus a DI registration, but the general recipe is any setup where a switch gets constructed in more than one place. serilog-settings-configuration lets you declare one in appsettings.json:

{
  "Serilog": {
    "LevelSwitches": { "$controlSwitch": "Information" },
    "MinimumLevel": { "ControlledBy": "$controlSwitch" }
  }
}

That declaration constructs a switch. Register another one in your container and you have two — the logger reads the config file's, your controller injects the container's.

The tell for both flavors is identical: you set the value, you read it back, it's correct, and nothing changes. If you see that, stop debugging your controller. Assign to MinimumLevel on an existing instance, and make sure exactly one place in your app constructs switches.

What none of this gets you

Every mechanism above changes the level for the whole process. Same level for every request, every customer, every tenant.

That's a real problem for a multi-tenant app, and it's asked directly: How to dynamically change Serilog's minimum log level for a tenant in a multi-tenant app and Multitenant LoggingLevelSwitch. The best statement of it is Serilog #1078:

In some situations it's desirable to have a way to enable deeper logging for some failing request without affecting the whole system and thus logging huge amount of logs from system under load and without a need to change config or implement functionality to change log level system-wide.

It's been open since 2017. The usual workaround is to raise the global level and filter downstream in your log aggregator. That works, and it means you now pay to format, serialize, and ship every debug record from every request in order to keep the 0.01% you wanted. If you've seen a logging bill spike after an incident, this is why.

Config-driven levels, including per-tenant

Quonfig treats log levels as config: stored in git, edited in a UI, pushed to every instance over SSE. Two integration points, one for each ecosystem.

Microsoft.Extensions.Logging:

using Quonfig.Sdk;
using Quonfig.Sdk.AspNetCore;
using Quonfig.Sdk.Extensions.Logging;
 
// The filter needs a live client, and ILoggingBuilder is only reachable
// before builder.Build() — so construct it explicitly here.
var quonfig = new Quonfig.Sdk.Quonfig(new QuonfigOptions
{
    SdkKey    = builder.Configuration["Quonfig:SdkKey"],
    LoggerKey = "log-level.acme-api",
});
await quonfig.InitAsync();
 
builder.Services.AddQuonfig(_ => { });          // registers IBoundQuonfig (scoped)
builder.Services.AddSingleton<IQuonfig>(quonfig);
builder.Logging.AddQuonfigFilter(quonfig);

Two notes on that. AddQuonfigFilter takes the client instance and ILoggingBuilder only exists before builder.Build(), so there's no way to wire the filter after the container is up — the explicit construction isn't optional. And AddQuonfig is still worth calling even though we're supplying our own client, because it's what registers the scoped IBoundQuonfig (and IHttpContextAccessor) that the per-tenant section below depends on. Register IQuonfig after it so your instance wins.

AddQuonfigFilter wraps the providers registered before it, so call it last in your logging setup. Providers added after it run alongside the filter rather than under it, which means they won't respect your levels — an easy thing to get wrong and a confusing thing to debug.

The filter's semantics are worth knowing, because they're deliberately conservative:

  • Quonfig has no rule for this category → defer to whatever the inner providers already decided.
  • Quonfig allows it → let it through.
  • Quonfig denies it → block it, regardless of the inner providers.

So adding it doesn't silently override your existing appsettings.json levels for categories you haven't configured in Quonfig. It only speaks up where you've told it to.

Serilog:

using Quonfig.Sdk.Serilog;
 
// Field, not a local — see the warning below.
static QuonfigLoggingLevelSwitchProvider _switches = null!;
 
_switches = new QuonfigLoggingLevelSwitchProvider(quonfig);
 
Log.Logger = new global::Serilog.LoggerConfiguration()
    .MinimumLevel.ControlledBy(_switches.GetSwitch(""))
    .MinimumLevel.Override("Acme.Billing", _switches.GetSwitch("Acme.Billing"))
    .WriteTo.Console()
    .CreateLogger();

This is the same LoggingLevelSwitch you'd have written by hand — Serilog can't tell the difference. The provider just owns the instances and re-evaluates every switch it has issued whenever config changes. Which, incidentally, kills both flavors of the bug above: switches come from exactly one place, and the provider mutates them rather than replacing them.

Pass an empty string for the root switch.

Do not write using var provider = .... The provider's Dispose() unsubscribes from the config-change event, and after that no switch it issued will ever update again. In top-level Program.cs a using happens to survive until process exit, so it looks fine — until someone refactors the setup into a helper method and live updates silently stop working, with no error anywhere. Hold it in a field or register it as a singleton.

Also note global::Serilog.LoggerConfiguration. With using Quonfig.Sdk; in scope, the identifier Serilog can bind to Quonfig.Sdk.Serilog instead of the real Serilog namespace. The SDK's own source uses the global:: prefix for this reason.

Quonfig log level list showing .NET category names with per-environment levels

One config per service — the service's LoggerKey — with MEL category names discriminated by rules inside it.

About hierarchy — read this before you assume it

Serilog's MinimumLevel.Override("Acme.Billing", ...) really is prefix-based: it covers Acme.Billing.Invoicing too. That's Serilog's own behavior and it works exactly as you'd expect.

What is not automatic is hierarchy on the Quonfig side. With LoggerKey set, the SDK does one lookup of that key and passes the category in as a context property named quonfig-sdk-logging.key. It does not walk parent keys. So a rule matching Acme.Billing does not implicitly cover Acme.Billing.Invoicing — you express that with a prefix operator:

OrderCriterionLevel
1quonfig-sdk-logging.key starts with Acme.Billing.InvoicingDEBUG
2quonfig-sdk-logging.key starts with Acme.BillingWARN
3everything elseINFO

Rules run top to bottom, so put the specific prefix above the general one. Watch the raw string match: a rule on Acme.Billing also matches Acme.BillingReports. Add the trailing dot if you mean strictly-children.

(The .NET SDK does have a second mode — leave LoggerKey unset and it treats the category itself as the config key, walking Acme.Billing.InvoicingAcme.BillingAcme → root. It's the only SDK that does. I'd skip it: the Quonfig UI requires config keys to begin with log-level., so keys created there won't match a raw category name.)

Two things that will trip you up

The enum is inverted relative to Microsoft's. Quonfig.Sdk.LogLevel is most-severe-first:

public enum LogLevel
{
    Fatal = 0,
    Error = 1,
    Warn  = 2,
    Info  = 3,
    Debug = 4,
    Trace = 5,
}

Microsoft.Extensions.Logging.LogLevel runs the other way, with Trace = 0. If you write a numeric comparison against either one, be certain which you're holding.

The names collide. Quonfig.Sdk.LogLevel and Microsoft.Extensions.Logging.LogLevel are both LogLevel, and you'll frequently have both namespaces in scope. Alias one:

using MelLogLevel = Microsoft.Extensions.Logging.LogLevel;

Per-tenant, finally

Here's the thing Serilog #1078 was asking for. Attach request context, and the level decision can look at it:

using System.Security.Claims;
 
app.UseQuonfigContext((http, ctx) =>
{
    if (http.User.FindFirst("tenant_id")?.Value is { } tenantId)
    {
        ctx["tenant"] = new ContextProperties { ["id"] = tenantId };
    }
    if (http.User.FindFirst(ClaimTypes.Email)?.Value is { } email)
    {
        ctx["user"] = new ContextProperties { ["email"] = email };
    }
});

Then a rule — tenant.id is one of northwind → DEBUG, everyone else → WARN — gives you full debug output for one customer and nothing extra for the rest.

But be careful about which API you use, because this is where it gets subtle.

QuonfigLoggingLevelSwitchProvider issues LoggingLevelSwitch objects, and a LoggingLevelSwitch is a process-global holder of a single level. It has no idea a request exists. Per-tenant rules cannot flow through it. A switch is one number for the whole process, by design.

Per-request targeting needs the context-aware API, called at the point where you know who you're serving:

public sealed class InvoiceService(IBoundQuonfig quonfig, ILogger<InvoiceService> logger)
{
    public void Compute(Invoice invoice)
    {
        if (quonfig.ShouldLog("Acme.Billing", Quonfig.Sdk.LogLevel.Debug))
        {
            logger.LogDebug("Computing invoice {Id}, {LineCount} lines",
                invoice.Id, invoice.Lines.Count);
        }
    }
}

IBoundQuonfig is registered scoped, so it already carries the context that UseQuonfigContext built for this request. That's the whole trick: the level decision happens somewhere that knows who's asking.

Why this one needs an explicit call, when nothing else did. AddQuonfigFilter and the level-switch provider are startup wiring — install them once and every ILogger in the app is gated, with no change to a single log statement. They can't do per-tenant, though, and it's not a design choice we made: ILogger.IsEnabled(LogLevel) takes no context argument. A filter sitting behind that signature can see the category and nothing else. (Go's SDK doesn't have this problem, because slog.Handler.Enabled(ctx, level) does get a context — so there, per-user targeting works through plain logger.DebugContext(...) calls.)

So use the filter for the 95% case — "this category is too noisy in prod" — and reach for ShouldLog only on the handful of paths where you'd genuinely want per-customer debugging. It's a few call sites, not a codebase-wide pattern.

Summary

  • There is no Microsoft.Extensions.Logging API for changing levels at runtime. Fowler was right in 2019 and it hasn't changed.
  • Reloading appsettings.json works, and for many teams it's enough. Know that ConfigMap propagation is slow and uneven.
  • Serilog's LoggingLevelSwitch is the right primitive. If it "doesn't work," count your instances before you debug anything else.
  • Nothing above does per-tenant. A switch is one number per process, and ILogger.IsEnabled has no context parameter — so per-request levels mean asking at the call site, on the few paths that need it.
  • Hierarchy is a rule, not magic. Serilog's Override is prefix-based; the Quonfig lookup is not. Write the prefix rule and order it above the general one.
  • Set it up before the incident. Every thread linked here is someone who needed this during an outage and found out the decision was made at startup.

Related: How to turn on debug logging for one user in production — the targeting problem across languages, including the security half nobody writes about.

Want to try it?

Quonfig stores your config in git. Feature flags, dynamic config, log levels, and secrets — all as files you own.