The Logger You Configure Once
Convey's logging package turns a config section into a fully enriched Serilog pipeline - console, file, Seq, ELK and Loki sinks, a live log-level switch, and one middleware with a typo in its name.
You can tell how a microservices estate will feel to operate from one artifact: a single log line, viewed in the aggregator. If it carries the service name, instance, version, environment and a correlation id — and every other service's lines carry the same fields with the same names — incidents are queries. If not, incidents are archaeology. The difference is never the logging library; it is whether enrichment was decided once, in a chassis, or re-decided per service by whoever copied the bootstrap last.
Convey.Logging, from DevMentors' open-source Convey toolkit, is the decided-once version, and this part reads it closely — including a live log-level endpoint most teams don't know they have, and my favorite bug in the entire toolkit, which is not a bug at all but a typo with tenure.
Host-level, not container-level
Unlike most Convey packages, logging does not hang off IConveyBuilder — it hooks the host itself:
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder => { ... })
.UseLogging();
UseLogging() wraps Serilog's UseSerilog(...), which replaces the logging provider before the app pipeline exists — so DI container messages, startup exceptions and hosted-service chatter all flow through the same pipe as your request logs. Anything wired later misses precisely the messages you need when a service won't boot. It reads two sections: app (from part 2 — service name, instance, version) and logger:
"logger": {
"level": "information",
"excludePaths": [ "/ping", "/metrics" ],
"console": { "enabled": true },
"file": { "enabled": true, "path": "logs/logs.txt", "interval": "day" },
"seq": { "enabled": true, "url": "http://localhost:5341", "apiKey": "<seq-api-key>" },
"minimumLevelOverrides": { "Microsoft": "warning" },
"tags": { "team": "fulfilment" }
}
Five sinks are supported as config toggles — console, rolling file, Seq, Elasticsearch (with basic auth), and Loki — which maps neatly onto the real lifecycle of an estate: console for Kubernetes, file for the VM stragglers, Seq for dev, ELK or Loki for production aggregation. No sink requires a code change; the sink decision belongs to operations, and this package actually delivers that.
The enrichment is the part I called the whole point:
// Convey.Logging/Extensions.cs
loggerConfiguration.Enrich.FromLogContext()
.MinimumLevel.ControlledBy(LoggingLevelSwitch)
.Enrich.WithProperty("Environment", environmentName)
.Enrich.WithProperty("Application", appOptions.Service)
.Enrich.WithProperty("Instance", appOptions.Instance)
.Enrich.WithProperty("Version", appOptions.Version);
Every event from every service carries the same four properties plus your tags, minimumLevelOverrides silences framework noise per namespace, excludePaths keeps /ping and /metrics (parts 15 and 20 — the pollers) out of the stream, and excludeProperties strips fields you'd rather not ship. None of this is hard. All of it is exactly what individual services get subtly wrong when left alone.
The live level switch
Notice MinimumLevel.ControlledBy(LoggingLevelSwitch) above. The minimum level is not a fixed value — it is a Serilog LoggingLevelSwitch, held statically, and the package ships an endpoint to move it at runtime:
app.UseEndpoints(endpoints => endpoints.MapLogLevelHandler("~/logging/level"));
POST /logging/level?level=debug and a running service starts emitting debug logs — no redeploy, no restart, no config rollout. If you have ever tried to catch a heisenbug that vanishes on restart, you know what this is worth: the restart destroys the state you are trying to observe, and this switch is how you avoid it.
Two operational notes travel with it. First, the endpoint ships with no authorization — it is a POST that changes production behavior, so it needs the same treatment as any actuator: bind it to an internal port, or wrap it in a policy before mapping it. Second, the switch is static and process-wide; behind a load balancer you are flipping one instance per call, which is usually what you want for targeted debugging and occasionally a surprise when you forget which instance you flipped. (Nothing flips it back, either — set a reminder.)
Correlation: baggage becomes scope
The second half of the package is AddCorrelationContextLogging(), a middleware that closes the loop the HTTP client opened in part 14. Its entire implementation:
// Convey.Logging/CorrelationContextLoggingMiddleware.cs
public Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var headers = Activity.Current.Baggage
.ToDictionary(x => x.Key, x => x.Value);
using (_logger.BeginScope(headers))
{
return next(context);
}
}
It lifts every key in the current Activity's baggage — the propagated key-value context that travels across process boundaries with W3C tracing — into a logging scope wrapped around the request. Whatever correlation values entered with the request now decorate every log line the request produces, in every service the request touches. Combined with the outbound headers from part 14 and the broker's message_context from part 9, this is the fabric: one id in, one queryable story out.
The implementation has a null-reference landmine — Activity.Current is dereferenced without a check, so if nothing created an Activity for the request (tracing disabled, unusual server setup), the middleware throws on every request. The dependency is real but invisible: this middleware requires the diagnostics machinery that the tracing package (part 21) or ASP.NET Core's default instrumentation provides. A one-character fix (?.) would make it degrade gracefully; as written, it fails loudly and confusingly.
And then there is the typo. The pipeline-side registration is:
app.UserCorrelationContextLogging();
User, not Use. It is not documentation lag — the public extension method is genuinely named UserCorrelationContextLogging, and Convey's own samples call it verbatim. Somebody typed it in 2019, the compiler didn't care, and renaming it now would break every consumer, so it stays. I am fond of it as an artifact: the strongest argument for API review I know fits in one method name. Your public API is append-only the moment someone depends on it; spellcheck accordingly.
Config keys that lie
One more source-reader's warning, because it will cost someone an afternoon: the sample appsettings.json shipped with the toolkit uses logger.applicationName and seq.token — and neither key exists on the options classes. The real bindings are the app section's service property and seq.apiKey. The impostor keys bind to nothing, silently, and the logs work anyway — just with a null application name that you notice three weeks later in a dashboard. Configuration binding's silent-ignore behavior means sample configs are API documentation, and here the documentation drifted. Options validation (ValidateDataAnnotations, or just asserting required fields on startup) is the vaccine.
The scorecard
Steal wholesale: host-level Serilog wiring, the four-property enrichment contract, config-toggled sinks, path exclusion for pollers, and — genuinely — the live level switch, secured. Fix in review: the unauthenticated actuator endpoint, the Activity.Current NRE, and silent config drift. Smile at: UserCorrelationContextLogging, the typo that teaches API governance better than any style guide.
Logs are one leg of observability. Next part is the second: metrics — and the curious fact that Convey ships two competing packages for them.