Skip to content

Custom HttpClient

Mykhailo Shevchuk edited this page Jun 6, 2026 · 3 revisions

v9 removes the ILokiHttpClient hierarchy (BaseLokiHttpClient, LokiHttpClient, LokiGzipHttpClient). The sink now works with a standard System.Net.Http.HttpClient, so any cross-cutting behaviour - gzip, retries, mTLS, bearer auth - is added with the normal .NET building blocks.

Two injection points

httpMessageHandler - the sink owns the client

Pass an HttpMessageHandler and the sink builds and owns the HttpClient around it. The sink still applies basic auth (credentials) and the tenant header.

.WriteTo.GrafanaLoki(
    "http://localhost:3100",
    httpMessageHandler: new GzipHandler())

httpClient - you own the client

Pass a fully built HttpClient (for example from IHttpClientFactory). The sink never disposes an injected client, and does not add auth/tenant headers to it - configure those yourself.

var httpClient = httpClientFactory.CreateClient("loki");
.WriteTo.GrafanaLoki("http://localhost:3100", httpClient: httpClient)

If both are supplied, httpClient wins and httpMessageHandler is ignored.

Gzip compression (replaces LokiGzipHttpClient)

// 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);
    }
}

Retries

Delivery-level retry and backoff are already handled by Serilog's batching (retryTimeLimit). If you also want per-request resilience (for example with Polly), add it as a handler:

// Microsoft.Extensions.Http.Polly
var handler = new PolicyHttpMessageHandler(retryPolicy)
{
    InnerHandler = new HttpClientHandler()
};
.WriteTo.GrafanaLoki("http://localhost:3100", httpMessageHandler: handler)

Bearer token / OAuth2

There is no first-class bearer-token option. Either set a default header on an injected HttpClient, or add a DelegatingHandler that attaches (and refreshes) the token per request:

public class BearerHandler : DelegatingHandler
{
    private readonly ITokenProvider _tokens;
    public BearerHandler(ITokenProvider tokens) : base(new HttpClientHandler()) => _tokens = tokens;

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        request.Headers.Authorization =
            new AuthenticationHeaderValue("Bearer", await _tokens.GetTokenAsync(ct));
        return await base.SendAsync(request, ct);
    }
}

mTLS / client certificates

var handler = new HttpClientHandler();
handler.ClientCertificates.Add(clientCertificate);
.WriteTo.GrafanaLoki("http://localhost:3100", httpMessageHandler: handler)

A DelegatingHandler passed to the sink must have an inner handler (the examples above use new HttpClientHandler()); otherwise the request pipeline has no terminus.

Clone this wiki locally