C# and .NET
InfiniteToken.Lumber is Lumber's .NET client. It encrypts every log with AES-256-GCM before it leaves your process, has no dependencies beyond the base library, and plugs into ILogger (ASP.NET Core, worker services, Azure Functions) and Serilog.
Install
There are three packages on NuGet. Add the one for how you log; each brings the client with it.
| Package | For |
|---|---|
InfiniteToken.Lumber | The client on its own |
InfiniteToken.Lumber.Extensions.Logging | ILogger: ASP.NET Core, the generic host, worker services |
InfiniteToken.Lumber.Serilog | A Serilog sink |
dotnet add package InfiniteToken.Lumber
.NET 8 or later, .NET Framework 4.6.2 or later, or anything else that runs .NET Standard 2.0.
Quick start
using InfiniteToken.Lumber;
using var lumber = new LumberClient(Environment.GetEnvironmentVariable("LUMBER_API_KEY"));
lumber.Info("Server started", new { port = 8080 });
lumber.Warn("Rate limit approaching", new { RequestId = "req-123", userId = 456 });
lumber.Error(exception, "Payment failed", new { TraceId = "trace-abc", orderId = 789 });
Make one client and share it: it's safe to use from any thread. Logs are sent in the background, so logging never waits on the network. Disposing the client sends the logs still queued, waiting 5 seconds at most, and so does the process exiting.
The API key comes from the Lumber app (see Getting Started). Keep it in an environment variable or your secrets manager, never in source control, and never in an app or game you ship to people (why).
ASP.NET Core and ILogger
dotnet add package InfiniteToken.Lumber.Extensions.Logging
It's a logging provider, so everything that already logs through ILogger, your code and the framework's, goes to Lumber too:
var builder = WebApplication.CreateBuilder(args);
builder.Logging.AddLumber(options => options.Environment = builder.Environment.EnvironmentName);
var app = builder.Build();
app.MapPost("/pay", (Order order, ILogger<Program> logger) =>
{
logger.LogInformation("Charging {OrderId} for {Total}", order.Id, order.Total);
return Results.Ok();
});
app.Run();
The API key comes from the LUMBER_API_KEY environment variable or configuration value (so dotnet user-secrets set LUMBER_API_KEY <key> works in development), or from Logging:Lumber:ApiKey. With no key anywhere, the provider warns once and sends nothing, so a machine without one keeps logging to the console as usual.
Which levels go to Lumber is set like any provider's, under its alias, Lumber. In appsettings.json:
{
"Logging": {
"LogLevel": { "Default": "Information" },
"Lumber": {
"LogLevel": { "Default": "Warning", "MyApp": "Information" },
"Environment": "Production"
}
}
}
Each log is sent at the matching level (Trace and Debug are DEBUG, Critical is FATAL), with the formatted message.
- The template's values (
OrderId,Total) go into the log's metadata, and so do the values of the logger's scopes. - In ASP.NET Core, each request's scope supplies its
RequestIdandTraceId, which become the log's own request and trace IDs;RequestPathgoes into metadata.IncludeScopes = falseleaves scopes out. - The logger's category goes into the context as
logger, an exception into metadata asexceptionandstackTrace, and an event ID aseventIdandeventName.
Worker services and console apps on the generic host add it the same way, with builder.Logging.AddLumber(). Without a host, make a factory and dispose it before exiting:
using var factory = LoggerFactory.Create(logging => logging.AddLumber());
var logger = factory.CreateLogger("Importer");
To log through ILogger and call the client yourself with one client, pass it in, and dispose it yourself:
var lumber = new LumberClient(builder.Configuration["LUMBER_API_KEY"]);
builder.Services.AddSingleton(lumber);
builder.Logging.AddLumber(lumber);
Serilog
dotnet add package InfiniteToken.Lumber.Serilog
using Serilog;
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.WriteTo.Lumber(Environment.GetEnvironmentVariable("LUMBER_API_KEY"), environment: "Production")
.CreateLogger();
Log.Information("Report ready for {Customer} in {Elapsed} ms", "acme", 2400);
Log.ForContext("RequestId", "req-123").Error(exception, "Payment failed for {@Order}", order);
Log.CloseAndFlush(); // sends the logs still queued
Each event is sent at the matching level (Verbose is DEBUG), its message rendered as Serilog's console sink renders it.
- Its properties go into the log's metadata, structures as nested objects.
RequestIdandTraceIdare the log's own request and trace IDs, andSourceContext(fromForContext<T>()) goes into its context aslogger.- When an
Activityis running, its trace and span IDs are sent too.
apiKey defaults to the LUMBER_API_KEY environment variable, and every argument can come from appsettings.json through Serilog.Settings.Configuration:
{
"Serilog": {
"WriteTo": [{ "Name": "Lumber", "Args": { "environment": "Production", "restrictedToMinimumLevel": "Information" } }]
}
}
WriteTo.Lumber(options => ...) takes every option below, and WriteTo.Lumber(lumber) a client you share with your own calls.
Log methods
| Method | Level |
|---|---|
Debug(message, data) | DEBUG |
Info(message, data) | INFO |
Warn(message, data) | WARN |
Error(message, data) | ERROR |
Fatal(message, data) | FATAL. It doesn't end the process |
Log(level, message, data) | Any level, as a LumberLevel |
Flush(timeout), FlushAsync(timeout, cancellationToken) | Waits for queued logs to send: false if timeout passed first |
Dispose(), DisposeAsync() | Sends what's queued, waiting ShutdownTimeout at most, and stops |
Each log method also takes an exception first: lumber.Error(exception, "Payment failed", data). Logging never throws and never blocks.
Data
The second argument is the log's data: an anonymous object, a dictionary, or any object, whose properties become the log's fields.
lumber.Info("User signed in", new
{
RequestId = "req-abc", // the log's request ID (requestId works too)
TraceId = "trace-xyz", // the log's trace ID (traceId works too)
context = new { region = "us-east" }, // merged into its context
metadata = new { plan = "pro" }, // merged into its metadata
userId = 123, // any other property goes into metadata
});
An explicit metadata wins over a colliding property. An exception goes into metadata as exception (its type and message) and stackTrace (the whole of ToString(), inner exceptions included). Values JSON has no type for are sent as text (a DateTime as ISO 8601, an enum as its name, a Guid, TimeSpan or Uri as you'd write it), a NaN as null, and a structure that contains itself as "[Circular]". Everything, the message included, is encrypted.
Options
var lumber = new LumberClient(new LumberOptions
{
ApiKey = Environment.GetEnvironmentVariable("LUMBER_API_KEY"),
Environment = "Production", // sent as `env` in every log's context
IncludeCaller = true, // send the file, line and method that logged
AutoContext = false, // don't send the process id, hostname and environment
Timeout = TimeSpan.FromSeconds(10), // how long to wait for Lumber to answer one log
MaxQueue = 10_000, // logs waiting to send before new ones are dropped
ShutdownTimeout = TimeSpan.FromSeconds(5), // how long queued logs get on Dispose or exit
HttpClient = httpClient, // your own HttpClient, for a proxy or custom TLS
OnWarning = message => Console.WriteLine(message), // the client's warnings, in place of standard error
});
Unless set, Environment is the ASPNETCORE_ENVIRONMENT or DOTNET_ENVIRONMENT environment variable, when either is set. The ILogger provider takes the same options, plus IncludeScopes, and can read them all from Logging:Lumber in configuration.
Delivery
Logs are sent in order, one POST /logs per log, over connections kept open between logs.
- A network error, a 5xx or no answer within
Timeoutcounts as a failed attempt. Each log gets 5 attempts, waiting 1, 2, 4, then 8 seconds between them, and is then dropped with a warning. - A log Lumber refuses (a 4xx, such as an invalid API key) is dropped at once with a warning, since sending it again won't help.
- When your team has sent its plan's logs for the month, Lumber answers 429: the client warns once and drops logs until the month turns.
- While Lumber can't be reached, up to
MaxQueuelogs wait; past that, new ones are dropped.
Warnings go to standard error unless you pass OnWarning. Never send them back into Lumber through a logger, or one failure becomes many.
Serverless
A serverless platform can freeze an instance as soon as it returns, with logs still queued. On AWS Lambda, wait for them before returning, with a time limit so a slow answer can't hold the function to its maximum duration:
public async Task<APIGatewayProxyResponse> Handle(APIGatewayProxyRequest request, ILambdaContext context)
{
lumber.Info("Handled request", new { path = request.Path });
await lumber.FlushAsync(TimeSpan.FromSeconds(10));
return new APIGatewayProxyResponse { StatusCode = 200 };
}
Make the client once, outside the handler, so warm invocations reuse it and its connection. Logging through ILogger there, share a client with AddLumber(lumber) so you can flush it. Azure Functions' isolated worker runs between invocations like any other process, so its logs send in the background, and the host flushes them when it shuts down.
Source
The client is open source: Lumber-DotNet on GitHub, published as InfiniteToken.Lumber, InfiniteToken.Lumber.Extensions.Logging and InfiniteToken.Lumber.Serilog on NuGet. It's tested against the same known answers for encryption and the wire format as Lumber's other clients.