Engineering
Change Log Levels At Runtime In .NET, Down To A Single Tenant
In 2019 someone asked the ASP.NET Core docs team: Update log level at runtime without app restart? David Fowler's answer, 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 — in Serilog, again, again, in NLog, per namespace.
TL;DR — Reloading
appsettings.jsongenuinely works, and Serilog'sLoggingLevelSwitchis the right primitive. When the switch "doesn't work," you almost certainly have two switch instances — mutate the one Serilog holds. And everything above is per-process: one level for every request, every tenant. Per-tenant needs the decision made where request identity is known — rules on request context, which is what Quonfig does: change a level in the UI, live on every instance in seconds, down to this one customer gets DEBUG.
The built-in answer: reloading configuration
Microsoft.Extensions.Logging reads its levels from IConfiguration, and the JSON provider watches the file. So this works 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 answers differently within a second or two. In Kubernetes, mount it from a ConfigMap and kubectl apply changes the fleet. For a lot of teams this is the end of the story. The catches:
- ConfigMap propagation is slow and uneven — up to a minute depending on kubelet sync, with some pods at DEBUG and some at INFO in the meantime.
- If your logging config is baked into the image or set via environment variables, none of this applies — you're 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, one switch each:
var efSwitch = new LoggingLevelSwitch(LogEventLevel.Warning);
Log.Logger = new LoggerConfiguration()
.MinimumLevel.ControlledBy(levelSwitch)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", efSwitch)
.WriteTo.Console()
.CreateLogger();Cheap to check, 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 — Control level switch dynamically and LoggingLevelSwitch Not Switching at the Run Time — report the same symptom: the level appears to change and the logs don't. One root cause covers almost every case:
The switch your logger is reading is not the switch you're mutating.
It shows up in two flavors. Flavor one — you replaced the switch instead of mutating it (this was #376):
// WRONG — Serilog is still holding the old object
_switches[category] = new LoggingLevelSwitch(newLevel);
// RIGHT — mutate the instance Serilog already has
_switches[category].MinimumLevel = newLevel;Flavor two — two constructions exist. serilog-settings-configuration lets appsettings.json declare a switch ("LevelSwitches": { "$controlSwitch": "Information" }), and that declaration constructs one. Register another in your DI container (#452 did it with a Lazy<T> static) and the logger reads the config file's switch while your controller injects the container's.
The tell for both is identical: you set the value, you read it back, it's correct, and nothing changes. If you see that, stop debugging your controller — make sure exactly one place in your app constructs switches, and assign to MinimumLevel on an existing instance.
What none of this gets you
Every mechanism above changes the level for the whole process — every request, every customer, every tenant. For a multi-tenant app that's the actual problem, and it's been asked directly: on Stack Overflow, in Serilog #2217, and best of all in 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…
Open since 2017. The usual workaround — raise the global level, filter in your log aggregator — means paying to format, serialize, and ship every debug record from every request to keep the 0.01% you wanted. Why that's the wrong place for the filter is its own post.
Config-driven levels
Quonfig treats log levels as config: stored in git, edited in a UI, pushed to every instance over SSE. One config per service, a level per environment:

This list is the operational surface. Flipping prod to DEBUG is an audited edit here — live everywhere in seconds, and turning it back off is the same edit.
There are two integration points, one per ecosystem.
Microsoft.Extensions.Logging:
using Quonfig.Sdk;
using Quonfig.Sdk.AspNetCore;
using Quonfig.Sdk.Extensions.Logging;
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 the scoped IBoundQuonfig
builder.Services.AddSingleton<IQuonfig>(quonfig);
builder.Logging.AddQuonfigFilter(quonfig);Three wiring rules, each of which bites if skipped:
- Construct the client before
builder.Build()— the filter needs a live client, andILoggingBuilderisn't reachable afterwards. - Call
AddQuonfigFilterlast in your logging setup. It wraps the providers registered before it; providers added after run beside it and ignore your levels. - Keep the
AddQuonfigcall even though you're supplying your own client — it registers the scopedIBoundQuonfigthat per-tenant targeting needs below. Register yourIQuonfigafter it so your instance wins.
The filter is deliberately conservative: no Quonfig rule for a category → defer to what the inner providers decided; rule allows → pass; rule denies → block. It only overrides your appsettings.json where you've actually configured something.
Serilog:
using Quonfig.Sdk.Serilog;
// Hold this in a field or a singleton — never `using var`.
static QuonfigLoggingLevelSwitchProvider _switches = null!;
_switches = new QuonfigLoggingLevelSwitchProvider(quonfig);
Log.Logger = new global::Serilog.LoggerConfiguration()
.MinimumLevel.ControlledBy(_switches.GetSwitch("")) // "" = root
.MinimumLevel.Override("Acme.Billing", _switches.GetSwitch("Acme.Billing"))
.WriteTo.Console()
.CreateLogger();These are ordinary LoggingLevelSwitch objects — Serilog can't tell the difference. The provider owns the instances and re-evaluates every switch it has issued when config changes, which structurally kills both flavors of the bug above: one construction site, mutation not replacement.
Two fine-print notes. The provider's Dispose() unsubscribes from config changes — after it runs, no switch it issued ever updates again, silently. That's why no using var: it happens to work in top-level Program.cs and then breaks when someone refactors the setup into a method. And global::Serilog is deliberate — with using Quonfig.Sdk; in scope, bare Serilog can bind to Quonfig.Sdk.Serilog.
Hierarchy is a rule, not magic
Serilog's MinimumLevel.Override("Acme.Billing", …) is prefix-based — it covers Acme.Billing.Invoicing too. That's Serilog's own behavior.
The Quonfig side is not hierarchical. The SDK looks up your one LoggerKey and passes the category in as a context property named quonfig-sdk-logging.key — it does not walk parent keys. You express hierarchy with prefix rules, specific above general:
| Order | Criterion | Level |
|---|---|---|
| 1 | quonfig-sdk-logging.key starts with Acme.Billing.Invoicing | DEBUG |
| 2 | quonfig-sdk-logging.key starts with Acme.Billing | WARN |
| 3 | everything else | INFO |
Mind the raw string match: a prefix rule on Acme.Billing also matches Acme.BillingReports — add the trailing dot if you mean strictly-children. (The SDK has a second mode that treats the category itself as the config key and walks parents; skip it — the UI requires keys to start with log-level., so keys created there never match a raw category name.)
Two sharp edges while you're here: Quonfig.Sdk.LogLevel is most-severe-first (Fatal = 0 … Trace = 5), the inverse of Microsoft's enum — be certain which you're holding before any numeric comparison. And since both are named LogLevel, alias one: using MelLogLevel = Microsoft.Extensions.Logging.LogLevel;.
Per-tenant, finally
Here's the thing Serilog #1078 was asking for. Attach request context once, in middleware:
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 };
}
});Now a rule — tenant.id is northwind → DEBUG, everyone else → WARN — gives one customer full debug output and nobody else anything extra.
But it can't flow through the filter or the level switches, and that's not a design choice we made: a LoggingLevelSwitch is one number for the whole process, and ILogger.IsEnabled(LogLevel) takes no context argument — a filter behind that signature can see the category and nothing else. Per-request targeting has to ask at the call site, 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 — and it's a few call sites on the paths where per-customer debugging pays off, not a codebase-wide pattern. The filter still handles the 95% case ("this category is too noisy in prod") with zero call-site changes.
Which answer do you need?
- One service, one level, right now. Reloading
appsettings.json. Know that ConfigMap propagation is slow and uneven. - Serilog, switchable from code.
LoggingLevelSwitch— and if it "doesn't work," count your instances before debugging anything else. - A fleet, changed from one place. Levels as config every instance reads — the list screenshot above, live in seconds over SSE.
- One tenant at DEBUG in production. Context + rules +
ShouldLogat the call site. Nothing process-wide can do this.
Whichever you pick: set it up before the incident. Every thread linked in this post is someone who needed the level changed right now and found out the decision was made at startup.
Related: How to turn on debug logging for one user in production · Change log levels at runtime in Go
Want to try it?
Quonfig stores your config in git. Feature flags, dynamic config, log levels, and secrets — all as files you own.