Serilog.Sinks.Grafana.Loki
Terms of use
By using this project or its source code, for any purpose and in any shape or form, you grant your implicit agreement to all the following statements:
- You condemn Russia and its military aggression against Ukraine
- You recognize that Russia is an occupant that unlawfully invaded a sovereign state
- You support Ukraine's territorial integrity, including its claims over temporarily occupied territories of Crimea and Donbas
- You reject false narratives perpetuated by Russian state propaganda
Glory to Ukraine! πΊπ¦
Table of contents
- What is this sink and Loki?
- What's new in V9
- Requirements
- Features
- Quickstart
- Configuration reference
- Labels
- Authentication and multi-tenancy
- Custom HttpClient
- Trace and span context
- Structured metadata
- JSON formatting and custom formatters
- Batching and delivery
- Migrating from V8
- Samples
- Inspiration and Credits
What is this sink and Loki?
The Serilog Grafana Loki sink project is a sink (basically a writer) for the Serilog logging framework. Structured log events are written to sinks and each sink is responsible for writing it to its own backend, database, store etc. This sink delivers the data to Grafana Loki, a horizontally-scalable, highly-available, multi-tenant log aggregation system. It allows you to use Grafana for visualizing your logs.
You can find more information about what Loki is over on Grafana's website here.
What's new in V9
V9 is a ground-up rewrite of the sink in F#, keeping a public API that remains idiomatic to call from C#. The rewrite fixes a class of long-standing structural bugs and modernizes the delivery pipeline:
- Built on Serilog 4.x native batching. The custom queue/timer/backoff stack is gone. Delivery now uses Serilog's
IBatchedLogEventSink, giving a bounded queue by default (50 000 events), async emission, and retry with exponential backoff β no more dropped batches on failure and no dispose-time deadlocks. - Immutable log-event pipeline. Labels are derived from a read-only view of the event. Properties promoted to labels now also remain in the log body, and the message template is never corrupted on retry.
- Streaming serialization. Payloads are written in a single forward pass with
Utf8JsonWriterover pooled buffers β no intermediate object graph and no intermediate strings. - Bring-your-own
HttpClient/HttpMessageHandler.The oldILokiHttpClient/LokiGzipHttpClienthierarchy is replaced by direct injection; gzip, retries, mTLS and bearer auth are now standardDelegatingHandlerconcerns. - New features: pluggable exception formatter (
ILokiExceptionFormatter),TraceId/SpanIdrouting to the body or Loki structured metadata, startup URI validation, and the log level exposed as a label using Grafana's vocabulary.
See Migrating from V8 for the full list of breaking changes.
Requirements
- .NET:
net8.0,net9.0, ornet10.0(earlier target frameworks are EOL and no longer supported). - Serilog:
4.3.1or later. - Transitive dependency:
FSharp.Coreis pulled in automatically. No other sink packages are required.
Features
- Batches and ships structured log events to Loki over its HTTP push API
- Serilog 4.x native batching: bounded in-memory queue, retry with exponential backoff, fully async emission
- Streaming
System.Text.Jsonserialization with pooled buffers (no intermediate object graph or strings) - Global and property-derived labels with deterministic stream grouping and key sanitisation
- Log level exposed as a label using Grafana's level vocabulary (optional)
- Basic authentication and multi-tenancy (
X-Scope-OrgID) support - Bring-your-own
HttpClient/HttpMessageHandlerβ gzip, retries, mTLS and bearer auth viaDelegatingHandler TraceId/SpanIdfrom the ambientActivity(OpenTelemetry) β written to the log body or as structured metadata- Loki structured metadata: per-line, non-indexed key/value pairs, queryable without a parser and without label cardinality cost (Loki 3.0+)
- Pluggable exception formatter and text formatter
- First-class
Serilog.Settings.Configuration(appsettings.json) support - No dependency on any other Serilog sink
Quickstart
The Serilog.Sinks.Grafana.Loki
NuGet package can be found here. Install it via one of the
following commands:
NuGet command:
Install-Package Serilog.Sinks.Grafana.Loki
.NET CLI:
dotnet add package Serilog.Sinks.Grafana.Loki
In the following example, the sink will send log events to Loki available on http://localhost:3100:
using Serilog;
using Serilog.Sinks.Grafana.Loki;
Log.Logger = new LoggerConfiguration()
.WriteTo.GrafanaLoki(
"http://localhost:3100",
[new LokiLabel { Key = "app", Value = "web_app" }])
.CreateLogger();
Log.Information("The god of the day is {@God}", odin);
The sink posts to
<uri>/loki/api/v1/push. The push path is appended automatically β pass only the base address (a path prefix such ashttp://gateway/lokiis supported and preserved).
Used together with Serilog.Settings.Configuration, the same
sink can be configured from appsettings.json:
{
"Serilog": {
"Using": [
"Serilog.Sinks.Grafana.Loki"
],
"MinimumLevel": {
"Default": "Debug"
},
"WriteTo": [
{
"Name": "GrafanaLoki",
"Args": {
"uri": "http://localhost:3100",
"labels": [
{
"key": "app",
"value": "web_app"
}
],
"propertiesAsLabels": [
"app"
]
}
}
]
}
}
Configuration reference
All options are passed as named arguments to WriteTo.GrafanaLoki(...). Only uri is required.
| Parameter | Type | Default | Description |
|---|---|---|---|
uri |
string |
β (required) | Loki base URI, e.g. http://localhost:3100. Validated at startup; must be an absolute http/https URI. |
labels |
LokiLabel[] |
[] |
Static labels attached to every stream. |
propertiesAsLabels |
string[] |
[] |
Log-event property names to promote to stream labels. |
propertiesAsStructuredMetadata |
string[] |
[] |
Property names to attach as per-line structured metadata (non-indexed; Loki 3.0+). |
handleLogLevelAsLabel |
bool |
true |
Add a level label using Grafana's level vocabulary. |
credentials |
LokiCredentials |
null |
Basic-auth credentials. From appsettings.json, an object with login/password. |
tenant |
string |
null |
Value for the X-Scope-OrgID multi-tenancy header; validated at startup. |
traceIdMode |
LokiFieldDestination |
None |
Where to write the event's TraceId: None, Body, or StructuredMetadata. |
spanIdMode |
LokiFieldDestination |
None |
Where to write the event's SpanId: None, Body, or StructuredMetadata. |
batchSizeLimit |
int |
1000 |
Maximum events per HTTP POST. |
queueLimit |
int |
50000 |
Maximum events buffered in memory before new events are dropped. |
period |
TimeSpan? |
1 s |
Flush interval. From appsettings.json, written as an "hh:mm:ss" string. |
eagerlyEmitFirstEvent |
bool |
true |
Flush immediately on the first event (surfaces misconfiguration early). |
retryTimeLimit |
TimeSpan? |
10 min |
Stop retrying a failed batch after this duration. From appsettings.json, an "hh:mm:ss" string. |
textFormatter |
ITextFormatter |
LokiJsonTextFormatter |
Per-event body formatter. |
exceptionFormatter |
ILokiExceptionFormatter |
LokiExceptionFormatter |
Exception serializer. |
httpClient |
HttpClient |
null |
Pre-built client (e.g. from IHttpClientFactory). The sink never disposes an injected client. |
httpMessageHandler |
HttpMessageHandler |
null |
Handler for the sink's own client (gzip, retries, β¦). Ignored when httpClient is set. |
restrictedToMinimumLevel |
LogEventLevel |
Verbose |
Minimum level handled by this sink. |
Note on
period/retryTimeLimit: in C# these areTimeSpan?(e.g.period: TimeSpan.FromSeconds(5)); leave them unset to use the defaults. Inappsettings.jsonthey are written as"hh:mm:ss"strings (e.g."00:00:05"), whichSerilog.Settings.Configurationconverts toTimeSpan.
A more complete C# example:
Log.Logger = new LoggerConfiguration()
.WriteTo.GrafanaLoki(
"http://localhost:3100",
labels: [new LokiLabel { Key = "app", Value = "my-service" }],
propertiesAsLabels: ["RequestPath"],
credentials: new LokiCredentials { Login = "user", Password = "pass" },
tenant: "my-tenant",
traceIdMode: LokiFieldDestination.StructuredMetadata,
queueLimit: 100_000,
period: TimeSpan.FromSeconds(2))
.CreateLogger();
Configuration details for appsettings.json are also documented
in the wiki.
Labels
Each log event is mapped to a Loki stream identified by its label set. Events that resolve to the same label set are grouped into one stream and ordered by timestamp.
Labels come from three sources, in descending priority:
- Global labels β from the
labelsoption, attached to every stream. - The
levellabel β added whenhandleLogLevelAsLabelistrue(the default). - Property-derived labels β from
propertiesAsLabels.
When keys collide, a higher-priority source wins; a property is silently skipped if its key matches a global label or the
reserved level key. Unlike V8, properties promoted to labels are kept in the log body as well β promotion no longer
removes them from the event.
Other rules:
- Key sanitisation: label keys must begin with a letter or underscore. Keys that start with a digit (for example
positional template tokens like
{0}) are prefixed withparam, becomingparam0. - Level vocabulary: Serilog levels map to Grafana's level names β
Verbose β trace,Debug β debug,Information β info,Warning β warning,Error β error,Fatal β fatal(previouslycritical).
Authentication and multi-tenancy
Basic authentication is configured with a credentials object:
.WriteTo.GrafanaLoki(
"http://localhost:3100",
credentials: new LokiCredentials { Login = "user", Password = "pass" })
Basic auth is applied only to a client the sink creates. If you inject your own
httpClient, configure itsAuthorizationheader yourself β the sink never mutates an injected client.
Multi-tenancy is configured with tenant, which sets the X-Scope-OrgID header:
.WriteTo.GrafanaLoki("http://localhost:3100", tenant: "tenant-1")
The tenant ID is validated at configuration time against
Loki's tenant ID rules β alphanumerics plus
!-_.*'(), at most 150 bytes, and not . or .. β an invalid value throws ArgumentException instead of producing
batches Loki would reject.
Bearer tokens / OAuth2 are not a first-class option β add them through the injected client, either by setting a
default Authorization header on an HttpClient or via a DelegatingHandler (see below).
Custom HttpClient
V9 accepts a standard HttpClient or HttpMessageHandler directly. Inject your own to add gzip compression, retries,
mTLS, bearer auth, or any other cross-cutting behaviour:
// GzipHandler.cs
using System.IO.Compression;
using System.Net.Http;
using System.Net.Http.Headers;
public class GzipHandler : DelegatingHandler
{
public GzipHandler() : base(new HttpClientHandler()) { }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct)
{
if (request.Content is not null)
{
var bytes = await request.Content.ReadAsByteArrayAsync(ct);
using var ms = new MemoryStream();
using (var gz = new GZipStream(ms, CompressionLevel.Fastest, leaveOpen: true))
await gz.WriteAsync(bytes, ct);
request.Content = new ByteArrayContent(ms.ToArray());
request.Content.Headers.ContentType =
new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
request.Content.Headers.ContentEncoding.Add("gzip");
}
return await base.SendAsync(request, ct);
}
}
// Inject via httpMessageHandler β the sink creates and owns the HttpClient
// (and still applies basic auth / tenant headers).
Log.Logger = new LoggerConfiguration()
.WriteTo.GrafanaLoki(
"http://localhost:3100",
httpMessageHandler: new GzipHandler())
.CreateLogger();
// Or inject a pre-built HttpClient (e.g. from IHttpClientFactory β the sink never disposes it).
var httpClient = httpClientFactory.CreateClient("loki");
Log.Logger = new LoggerConfiguration()
.WriteTo.GrafanaLoki(
"http://localhost:3100",
httpClient: httpClient)
.CreateLogger();
Trace and span context
traceIdMode and spanIdMode control where the event's TraceId / SpanId (populated by Serilog 4.x from the ambient
Activity) are written. Each takes a LokiFieldDestination:
| Value | Effect |
|---|---|
None (default) |
Not emitted. |
Body |
Written as a TraceId / SpanId field in the JSON log body. |
StructuredMetadata |
Attached as Loki structured metadata β recommended for trace IDs. |
.WriteTo.GrafanaLoki(
"http://localhost:3100",
traceIdMode: LokiFieldDestination.StructuredMetadata,
spanIdMode: LokiFieldDestination.StructuredMetadata)
From appsettings.json the mode binds from its name: "traceIdMode": "StructuredMetadata".
Trace context is typically populated for you by Serilog.AspNetCore / Serilog.Extensions.Logging; outside those, an
active Activity must be present for the IDs to be emitted.
Structured metadata
Structured metadata attaches per-line
key/value pairs to a log entry without indexing them as labels. Unlike propertiesAsLabels it does not create new
streams (no cardinality cost), and unlike a body field it is queryable without a parser stage:
{app="web_app"} | RequestId="abc-123" # structured metadata β no parser needed
{app="web_app"} | json | RequestId="abc-123" # the equivalent for a body field
That makes it the right home for high-cardinality identifiers such as request, user, or trace IDs. Two sources feed it:
propertiesAsStructuredMetadataβ property names to attach as metadata (the property is also kept in the body).traceIdMode/spanIdModeset toStructuredMetadataβ routesTraceId/SpanIdthere instead of the body.
.WriteTo.GrafanaLoki(
"http://localhost:3100",
propertiesAsStructuredMetadata: ["RequestId", "UserId"],
traceIdMode: LokiFieldDestination.StructuredMetadata)
It is emitted as the optional third element of each push entry:
"values": [
[ "1700000000000000000", "{\"Message\":\"...\"}", { "RequestId": "abc-123", "TraceId": "..." } ]
]
Requires Loki 3.0+ (or 2.9 with
allow_structured_metadataenabled on a TSDB v13 schema). Against an older Loki, or one with structured metadata disabled, such a push is rejected β leave these options unset (the default) to stay compatible.
For the full picture β the three-tier model (labels vs structured metadata vs body), querying, and Loki configuration β see Structured metadata in the wiki.
JSON formatting and custom formatters
By default the sink uses LokiJsonTextFormatter, which renders each log entry's body as a JSON object. This makes logs
easy to filter in Loki β see Grafana's write-up on
querying JSON logs.
The resulting push payload looks like:
{
"streams": [
{
"stream": { "app": "web_app", "level": "info" },
"values": [
[ "1700000000000000000", "{\"Message\":\"...\",\"MessageTemplate\":\"...\"}" ]
]
}
]
}
Each body object contains Message, MessageTemplate, an optional Exception, the TraceId / SpanId fields (when
their mode is Body), and every event property. Property names that collide with these reserved keys (Message,
MessageTemplate, Exception, TraceId, SpanId) are prefixed with _.
Custom text formatter. Implement Serilog.Formatting.ITextFormatter, or subclass LokiJsonTextFormatter and
override Format or SanitizePropertyName, then pass it via textFormatter:
{
"Serilog": {
"WriteTo": [
{
"Name": "GrafanaLoki",
"Args": {
"uri": "http://localhost:3100",
"textFormatter": "My.Awesome.Namespace.MyTextFormatter, MyCoolAssembly"
}
}
]
}
}
Custom exception formatter. Exception serialization is delegated to ILokiExceptionFormatter. The default
(LokiExceptionFormatter) recursively writes Type, Message, Source, StackTrace and inner exceptions. Replace it
to scrub PII, change the shape, or suppress stack traces:
using System.Text.Json;
using Serilog.Sinks.Grafana.Loki;
public class CompactExceptionFormatter : ILokiExceptionFormatter
{
public void Format(Utf8JsonWriter writer, Exception exception)
{
writer.WriteStartObject();
writer.WriteString("type", exception.GetType().Name);
writer.WriteString("message", exception.Message);
writer.WriteEndObject();
}
}
.WriteTo.GrafanaLoki(
"http://localhost:3100",
exceptionFormatter: new CompactExceptionFormatter())
Batching and delivery
Delivery is handled by Serilog 4.x's native batching infrastructure:
- Events are buffered and flushed every
period(default 1 s) or oncebatchSizeLimit(default 1000) is reached. - The in-memory queue is bounded at
queueLimit(default 50 000). When the queue is full, new events are dropped rather than growing memory without limit. - On a failed POST, the batch is retried with exponential backoff up to
retryTimeLimit(default 10 min), after which it is dropped so the pipeline can make progress. - Emission is fully asynchronous, so disposing the logger (
Log.CloseAndFlush()) flushes cleanly without deadlocks.
Delivery problems are reported through Serilog's SelfLog. Enable it during development to see HTTP errors and dropped
batches:
Serilog.Debugging.SelfLog.Enable(Console.Error);
Migrating from V8
V9 is a major release with breaking changes. The highlights:
| Area | V8 | V9 |
|---|---|---|
| Target frameworks | netstandard2.0, net5.0βnet8.0 |
net8.0, net9.0, net10.0 |
| Serilog | 2.x / 3.x | 4.3.1+ (native batching) |
| HTTP client | ILokiHttpClient / LokiGzipHttpClient subclasses |
Inject httpClient / httpMessageHandler; gzip via DelegatingHandler |
| Batch size parameter | batchPostingLimit |
renamed to batchSizeLimit |
| Level label | always injected, collisions renamed | handleLogLevelAsLabel (default true); Fatal β fatal (was critical) |
| Property β label | removed the property from the body | property is kept in the body |
| Reserved-property renaming | IReservedPropertyRenamingStrategy |
removed β pipeline is immutable; reserved body keys are prefixed with _ |
leavePropertiesIntact |
flag | removed β no longer needed |
useInternalTimestamp |
flag | removed |
| Queue | unbounded by default | bounded at queueLimit (default 50 000) |
| Exception formatting | hardcoded | pluggable ILokiExceptionFormatter |
| Trace context | not supported | traceIdMode / spanIdMode (body or structured metadata) |
| URI validation | on first request | at logger configuration time |
FSharp.Core becomes a transitive dependency of all consumers.
The full, maintained list of breaking changes lives in the wiki.
Samples
Runnable examples live in the
samples folder:
Serilog.Sinks.Grafana.Loki.Sampleβ a minimal console app.Serilog.Sinks.Grafana.Loki.SampleWebAppβ an ASP.NET Core app configured fromappsettings.json.
Inspiration and Credits
- Serilog.Sinks.Loki β the original Serilog Loki sink, which this project was born from and inspired by.
- Serilog.Sinks.Loki.YetAnother β an allocation-conscious Loki sink whose performance findings inspired parts of the V9 optimization work; it also serves as the external yardstick in our benchmarks.