Logsmith 0.3.2

dotnet add package Logsmith --version 0.3.2
                    
NuGet\Install-Package Logsmith -Version 0.3.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Logsmith" Version="0.3.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Logsmith" Version="0.3.2" />
                    
Directory.Packages.props
<PackageReference Include="Logsmith" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Logsmith --version 0.3.2
                    
#r "nuget: Logsmith, 0.3.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Logsmith@0.3.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Logsmith&version=0.3.2
                    
Install as a Cake Addin
#tool nuget:?package=Logsmith&version=0.3.2
                    
Install as a Cake Tool

Logsmith CI

Zero-allocation, source-generated structured logging for .NET 10.

Logsmith is a logging framework where the source generator is the framework. Every log method is analyzed at compile time, and the generator emits fully specialized, zero-allocation UTF8 code tailored to your exact parameters. No reflection. No boxing. No runtime parsing of message templates. No middleware. Just direct, type-safe writes from your call site to your output sink.


Table of Contents

Packages

Name NuGet Description
Logsmith Logsmith Runtime library with public types, sinks, and the bundled source generator. Use this when multiple projects share log definitions.
Logsmith.Generator Logsmith.Generator Source generator only. Emits all infrastructure as internal types with zero runtime dependency.
Logsmith.Extensions.Logging Logsmith.Extensions.Logging Microsoft.Extensions.Logging bridge. Routes ILogger calls through Logsmith sinks.

Why Logsmith Exists

Most .NET logging frameworks share a common design: a runtime library parses message templates, boxes value-type arguments into object[], and dispatches through multiple abstraction layers before bytes ever reach an output target. This design prioritizes plugin ecosystems and runtime flexibility over raw throughput.

For applications where logging sits on the hot path, those costs are measurable. Game engines evaluating draw calls at 144 frames per second. Trading systems processing market data where microseconds matter. Libraries that want structured logging without imposing transitive dependencies on their consumers. NativeAOT deployments where reflection is unavailable and binary size matters.

The Microsoft.Extensions.Logging LoggerMessage source generator demonstrated that compile-time code generation could eliminate much of this overhead. Logsmith takes that idea to its conclusion: the source generator does not supplement a runtime framework. It replaces it entirely.

The generator reads your method declarations at build time. It knows the concrete types of every parameter. It emits direct UTF8 formatting calls, pre-computed property names, and type-specific serialization paths. In standalone mode, the consuming project's compiled output contains zero Logsmith DLLs. Everything is source-generated into your assembly.


What Logsmith Is

  • A C# incremental source generator that emits fully specialized logging method bodies at compile time.
  • A zero-allocation logging pipeline that stays in UTF8 from input to output.
  • A structured logging system that captures typed properties alongside human-readable messages.
  • A dual-mode package: reference the runtime library for shared types across projects, or use the generator alone for fully self-contained internal logging with no runtime dependency.
  • A compile-time safety net with diagnostics that catch template mismatches, missing parameters, and unsupported types before your code ever runs.
  • A framework that supports in parameters for passing large structs by reference, eliminating unnecessary copies on the logging hot path.
  • A framework that uses [Conditional("DEBUG")] to strip debug and trace log calls from release binaries at the compiler level, not just filter them at runtime.

What Logsmith Is Not

  • Not a drop-in replacement for Microsoft.Extensions.Logging. The optional Logsmith.Extensions.Logging bridge routes MEL ILogger calls through Logsmith sinks, but MEL-native sink packages (Seq, Datadog, Application Insights, OpenTelemetry) expect MEL's ILoggerProvider ecosystem. Logsmith defines its own ILogSink contract for native sinks.
  • Not a runtime-configurable logging framework. Log levels can be changed at runtime (including via environment variable polling and config file watching), and sinks can be reconfigured, but message templates and parameter bindings are fixed at compile time. There is no runtime expression evaluator or dynamic template engine.

Comparison with Other Frameworks

Capability Logsmith MEL + LoggerMessage ZLogger Serilog NLog
Source-generated method bodies Yes Yes Yes No No
Zero runtime dependency mode Yes (standalone) No (requires MEL) No (requires MEL) No No
Zero allocation hot path Yes Partial (MEL infra allocates) Yes No No
UTF8 end-to-end Yes No (UTF16 strings) Yes No No
Structured logging Yes (Utf8JsonWriter) Yes Yes Yes Yes
Compile-time level stripping Yes ([Conditional]) No No No No
No boxing of value types Yes Yes (generated path) Yes No (object[] params) No (object[] params)
No reflection Yes Yes Partial No (used in enrichers) No (used in layouts)
NativeAOT compatible Yes Yes Yes Partial Partial
Compile-time diagnostics Yes (LSMITH001-007) Yes Limited No No
Log sampling / rate limiting Yes (compile-time) No No No No
Scoped context (AsyncLocal) Yes (LogScope) Yes (ILogger.BeginScope) Yes Yes (LogContext) Yes (ScopeContext)
Dynamic level switching Yes (env var, file) Yes (IOptionsMonitor) Yes Yes (LoggingLevelSwitch) Yes (config reload)
Custom type serialization ILogStructurable ILogger.BeginScope IZLoggerFormattable Destructure policies Custom layout renderers
MEL ecosystem compatibility Yes (bridge package) Native Native Via Serilog.Extensions.Logging Via NLog.Extensions.Logging
DI container required No Typically yes Typically yes No No
Transitive dependencies Zero (standalone) MEL abstractions MEL + ZLogger Serilog + sinks NLog

Benchmarks

Full benchmark results comparing Logsmith against MEL, Serilog, NLog, and ZLogger are available in docs/benchmarks.md. Benchmarks cover simple messages, templated messages, multi-parameter formatting, exception logging, disabled-level guard checks, and scoped context overhead.


Features

Zero-Allocation Logging Pipeline

The generator emits direct calls to IUtf8SpanFormattable.TryFormat for every value-type parameter, writing UTF8 bytes to stack-allocated buffers. No ToString(). No intermediate strings. No heap allocations on the logging hot path.

Compile-Time Conditional Level Stripping

Log methods at or below a configurable severity threshold receive [Conditional("DEBUG")], causing the C# compiler to erase call sites entirely from release builds. The method body, the argument evaluation, and the call itself are absent from the compiled IL.

Dual-Mode Packaging

Reference the Logsmith NuGet package for shared public types and the bundled generator. Or reference Logsmith.Generator alone, and the generator emits all infrastructure as internal types. The generator detects which mode applies automatically.

Structured and Text Output

Every log method generates two output paths: a human-readable UTF8 text message and a structured property set written through System.Text.Json.Utf8JsonWriter. Sinks choose which representation they consume.

Compile-Time Validation

The generator produces diagnostics for template placeholder mismatches, unreferenced parameters, invalid method signatures, and unsupported parameter types. Errors surface in the IDE before the code compiles.

Built-In Sinks

Six sinks ship with the framework: ConsoleSink with ANSI color, FileSink with async-buffered writing, size and time-based rolling, and multi-process support, StreamSink for writing to any Stream, DebugSink for IDE output windows, RecordingSink for test assertions, and NullSink for benchmarking.

Pluggable Log Formatting

All sinks accept an ILogFormatter for customizing log line prefixes and suffixes. DefaultLogFormatter provides timestamp, level, and category formatting. NullLogFormatter outputs raw messages only. Custom formatters write directly to IBufferWriter<byte> for zero-allocation output.

Format Specifiers in Templates

Message templates support standard .NET format strings ({value:F2}) and JSON serialization ({obj:json}). Format specifiers are parsed at compile time and emitted as static code — no runtime template parsing.

Scoped Context Enrichment

LogScope provides AsyncLocal-based ambient property stacking. Push key-value properties onto the scope, and they automatically appear on every log entry within that scope — including across async/await boundaries. Scopes are shared between direct Logsmith calls and MEL bridge calls.

Log Sampling and Rate Limiting

Compile-time attributes on [LogMessage] cause the generator to emit lightweight counter-based guards. SampleRate = N emits 1-in-N messages. MaxPerSecond = N caps per-second throughput. Both use lock-free Interlocked operations with zero runtime cost when not configured.

Dynamic Level Switching

Opt-in monitors for adjusting log levels at runtime without full reconfiguration. WatchEnvironmentVariable polls an env var on a timer. WatchConfigFile watches a JSON file with debounced reload. Both swap the config lock-free.

Global Exception Handler

CaptureUnhandledExceptions wires AppDomain.UnhandledException and TaskScheduler.UnobservedTaskException to a user-provided callback with opt-in SetObserved() support.

Microsoft.Extensions.Logging Bridge

The Logsmith.Extensions.Logging package provides ILoggerProvider and ILogger implementations that route MEL log calls through Logsmith sinks. BeginScope delegates to LogScope, so scopes are shared between MEL and direct Logsmith usage.

Internal Error Handling

Sink exceptions in LogManager.Dispatch are caught and routed to a configurable InternalErrorHandler. A failed sink does not prevent other sinks from executing or crash the application.

Thread Info Capture

Every LogEntry carries ThreadId and ThreadName captured at the call site. Available for structured sinks and explicit template references ({threadId}, {threadName}). Not rendered in text output by default.


Installation

<PackageReference Include="Logsmith" Version="1.0.0" />

This provides the runtime library (public types, sinks, LogManager) and the source generator. The generator is bundled as an analyzer and does not appear in your build output.

Standalone (zero runtime dependency)

<PackageReference Include="Logsmith.Generator" Version="1.0.0"
    OutputItemType="Analyzer"
    ReferenceOutputAssembly="false" />

The generator emits all infrastructure types as internal into your assembly. No Logsmith DLLs appear in your build output.


Quick Start

1. Initialize at startup

LogManager.Initialize(config =>
{
    config.MinimumLevel = LogLevel.Debug;
    config.AddConsoleSink();
    config.AddFileSink("logs/app.log", rollingInterval: RollingInterval.Daily);
    config.InternalErrorHandler = ex => Console.Error.WriteLine(ex);
});

2. Declare log methods

[LogCategory("Renderer")]
public static partial class RenderLog
{
    [LogMessage(LogLevel.Debug, "Draw call {drawCallId} completed in {elapsedMs}ms")]
    public static partial void DrawCallCompleted(int drawCallId, double elapsedMs);

    [LogMessage(LogLevel.Error, "Shader compilation failed: {shaderName}")]
    public static partial void ShaderFailed(string shaderName, Exception ex);
}

3. Call them

public class Renderer
{
    public void Draw(int id)
    {
        var sw = Stopwatch.StartNew();
        // ... rendering work ...
        sw.Stop();

        RenderLog.DrawCallCompleted(id, sw.Elapsed.TotalMilliseconds);
    }
}

No logger injection. No sink parameter. No service locator. The generated code dispatches through the static LogManager configured at startup.


Declaring Log Methods

Log methods are declared as static partial methods inside partial classes. The generator provides the implementation.

public static partial class NetworkLog
{
    [LogMessage(LogLevel.Information, "Connection established to {endpoint} in {latencyMs}ms")]
    public static partial void ConnectionEstablished(string endpoint, double latencyMs);

    [LogMessage(LogLevel.Warning, "Packet loss detected: {lossPercent}% over {windowSeconds}s")]
    public static partial void PacketLoss(float lossPercent, int windowSeconds);

    [LogMessage(LogLevel.Critical, "Connection to {endpoint} lost")]
    public static partial void ConnectionLost(string endpoint, Exception ex);
}

Requirements:

  • The containing class must be partial.
  • The method must be static partial.
  • The method must return void.
  • Parameter names referenced in the message template are matched case-insensitively.
  • Parameters may use the in modifier to pass large structs by reference (see Performance: in Parameters).

Categories

The [LogCategory] attribute sets the category string attached to every log entry from that class. If omitted, the class name is used. The generator emits a public const string CategoryName field on each log class, which can be used for type-safe per-category configuration via SetMinimumLevel<T>().

[LogCategory("Audio")]
public static partial class AudioLog { ... }
// Generated: public const string CategoryName = "Audio";

// No attribute: category defaults to "PhysicsLog"
public static partial class PhysicsLog { ... }
// Generated: public const string CategoryName = "PhysicsLog";

Message Templates

Explicit templates

Provide a message string with {parameterName} placeholders that map to method parameters by name (case-insensitive):

[LogMessage(LogLevel.Debug, "Frame {frameId} rendered {triangleCount} triangles in {elapsedMs}ms")]
public static partial void FrameRendered(int frameId, long triangleCount, double elapsedMs);

The generator pre-splits the template at compile time into alternating literal segments and parameter slots. At runtime, it writes UTF8 literals directly and formats each parameter through its IUtf8SpanFormattable implementation.

Template-free mode

Omit the message string. The generator constructs the message automatically from the method name and parameter names:

[LogMessage(LogLevel.Debug)]
public static partial void FrameRendered(int frameId, long triangleCount, double elapsedMs);
// Generated message: "FrameRendered frameId={frameId} triangleCount={triangleCount} elapsedMs={elapsedMs}"

This mode guarantees that renaming a parameter via IDE refactoring keeps the message in sync. The method name is split from PascalCase for the structured event name.

EventId

Each log method receives a stable EventId derived from a hash of the fully qualified method name. To override:

[LogMessage(LogLevel.Information, "Player joined: {playerName}", EventId = 5001)]
public static partial void PlayerJoined(string playerName);

Log Levels and Conditional Compilation

Log levels

public enum LogLevel : byte
{
    Trace,
    Debug,
    Information,
    Warning,
    Error,
    Critical,
    None
}

Runtime filtering

LogManager performs an enum comparison before dispatching. If the entry's level is below the configured minimum, no work is done:

// Fast path: single enum comparison, no allocations
if (level < _config.MinimumLevel) return;

Per-category overrides are supported. You can use a magic string or the type-safe generic overload, which reads the CategoryName constant emitted by the generator on each log class:

LogManager.Initialize(config =>
{
    config.MinimumLevel = LogLevel.Information;
    config.SetMinimumLevel("Renderer", LogLevel.Debug);         // by string
    config.SetMinimumLevel<RenderLog>(LogLevel.Debug);          // by type (recommended)
});

The generic overload resolves the category from the generated public const string CategoryName field. If the type has a [LogCategory] attribute, the constant reflects that name; otherwise it defaults to the class name.

Compile-time stripping

The generator applies [Conditional("DEBUG")] to log methods at or below a configurable severity threshold. The C# compiler erases these call sites entirely from release builds. No IL is emitted. Arguments are not evaluated.

Configure the threshold in your project file:

<PropertyGroup>
    
    <LogsmithConditionalLevel>Debug</LogsmithConditionalLevel>
</PropertyGroup>
Setting Methods stripped in Release
Trace Trace only
Debug (default) Trace, Debug
Information Trace, Debug, Information
None Nothing stripped

To exempt a specific method from stripping regardless of the threshold:

[LogMessage(LogLevel.Debug, "Critical diagnostic: {value}", AlwaysEmit = true)]
public static partial void CriticalDiagnostic(double value);

Sinks

Built-in sinks

ConsoleSink

Writes ANSI-colored UTF8 directly to Console.OpenStandardOutput(), bypassing Console.WriteLine and its encoding overhead.

config.AddConsoleSink();
config.AddConsoleSink(colored: false); // disable ANSI colors

Output format:

[12:34:56 DBG] Renderer: Draw call 42 completed in 1.3ms
[12:34:56 INF] Renderer: Frame rendered 100 with 50000 triangles
[12:34:56 WRN] Audio: Buffer underrun detected
[12:34:56 ERR] Network: Connection to 10.0.0.1:8080 lost
FileSink

Async-buffered file writing using Channel<T>. The calling thread enqueues a buffered copy and returns immediately. A background task flushes to disk.

config.AddFileSink("logs/app.log");
config.AddFileSink("logs/app.log", rollingInterval: RollingInterval.Daily);
config.AddFileSink("logs/app.log", rollingInterval: RollingInterval.Hourly, maxFileSizeBytes: 50_000_000);
config.AddFileSink("logs/app.log", shared: true); // multi-process safe

Rolling intervals: None, Hourly, Daily, Weekly, Monthly. Size-based rolling and time-based rolling can be combined. In shared mode (shared: true), the file is opened with FileShare.ReadWrite for safe concurrent appends from multiple processes.

StreamSink

Writes to any Stream via async-buffered Channel<T>. Useful for network streams, memory streams, or Console.OpenStandardOutput().

config.AddStreamSink(networkStream, leaveOpen: true);

When leaveOpen is true, the stream is flushed but not disposed when the sink is disposed.

DebugSink

Writes to System.Diagnostics.Debug, which routes to the IDE output window. Useful during development. Automatically stripped from release builds by the runtime.

config.AddDebugSink();
RecordingSink

Captures log entries to an in-memory list for test assertions. See Testing.

var sink = new RecordingSink();
config.AddSink(sink);
NullSink

Discards all output. Useful for benchmarking the logging pipeline itself or for disabling logging without removing call sites.

config.AddNullSink();

Sink filtering by category

Any sink can be restricted to specific categories:

config.AddFileSink("logs/render.log", category: "Renderer");
config.AddFileSink("logs/network.log", category: "Network");
config.AddConsoleSink(); // receives everything

Log Formatting

All sinks accept an ILogFormatter parameter that controls the prefix and suffix around each log message. Formatters write directly to IBufferWriter<byte> for zero-allocation output.

DefaultLogFormatter

The default formatter produces [HH:mm:ss.fff LVL Category] prefixes for console output and [yyyy-MM-dd HH:mm:ss.fff LVL Category] for file output, with newline suffixes and exception rendering.

config.AddConsoleSink(formatter: new DefaultLogFormatter(includeDate: false));
config.AddFileSink("app.log", formatter: new DefaultLogFormatter(includeDate: true));

NullLogFormatter

Outputs raw messages with no prefix or suffix:

config.AddFileSink("raw.log", formatter: NullLogFormatter.Instance);

Custom formatters

Implement ILogFormatter for custom formatting:

public sealed class JsonLineFormatter : ILogFormatter
{
    public void FormatPrefix(in LogEntry entry, IBufferWriter<byte> output) { /* ... */ }
    public void FormatSuffix(in LogEntry entry, IBufferWriter<byte> output) { /* ... */ }
}

Format Specifiers

Message templates support format specifiers after a colon inside placeholders. Format specifiers are parsed at compile time and emitted as static code.

Standard .NET format strings

[LogMessage(LogLevel.Information, "Price={price:F2}, Date={date:yyyy-MM-dd}")]
public static partial void LogTransaction(decimal price, DateTime date);
// Output: "Price=19.99, Date=2026-02-18"

The generator emits writer.WriteFormatted(value, "F2") which passes the format string directly to IUtf8SpanFormattable.TryFormat.

JSON serialization (:json)

[LogMessage(LogLevel.Debug, "Config={config:json}")]
public static partial void LogConfig(object config);
// Output: Config={"key":"value","nested":{"a":1}}

The :json specifier uses System.Text.Json.JsonSerializer.SerializeToUtf8Bytes for the text path and JsonSerializer.Serialize(writer, value) for the structured path. Note that :json allocates (the byte[] from the serializer) — it is opt-in for complex objects.

The generator emits LSMITH006 warning when :json is applied to primitive types (int, string, bool, etc.) where default formatting is more efficient.


Structured Output

Every log method generates two output paths. Text sinks receive a pre-formatted UTF8 byte span. Structured sinks receive typed property writes through System.Text.Json.Utf8JsonWriter.

A structured sink (such as a JSON file sink or a network sink) implements IStructuredLogSink:

public interface IStructuredLogSink : ILogSink
{
    void WriteStructured<TState>(
        in LogEntry entry,
        TState state,
        WriteProperties<TState> propertyWriter)
        where TState : allows ref struct;
}

The generator emits a static lambda for each log method that writes properties without closure allocations:

// Generated for: DrawCallCompleted(int drawCallId, double elapsedMs)
static (writer, state) =>
{
    writer.WriteNumber("drawCallId"u8, state.drawCallId);
    writer.WriteNumber("elapsedMs"u8, state.elapsedMs);
}

The property names are UTF8 string literals derived from the parameter names at compile time.


Scoped Context

LogScope provides ambient key-value properties that attach to every log entry within a scope. Scopes use AsyncLocal<T> and propagate through async/await boundaries.

Basic usage

using (LogScope.Push("RequestId", "req-abc-123"))
{
    Log.ProcessingOrder("ORD-001", "Alice");
    // Text output: "Processing order ORD-001 for Alice [RequestId=req-abc-123]"
}
// Outside the scope — no enrichment
Log.ProcessingOrder("ORD-002", "Bob");
// Text output: "Processing order ORD-002 for Bob"

Nested scopes

Scopes nest naturally. Inner properties appear alongside outer properties:

using (LogScope.Push("RequestId", "req-abc-123"))
{
    using (LogScope.Push("UserId", "user-42"))
    {
        Log.ProcessingOrder("ORD-001", "Alice");
        // Text output: "Processing order ORD-001 for Alice [UserId=user-42] [RequestId=req-abc-123]"
    }
}

Multi-property push

Push multiple properties in a single call:

var props = new KeyValuePair<string, string>[]
{
    new("RequestId", "req-abc-123"),
    new("TenantId", "tenant-7"),
};
using (LogScope.Push(props))
{
    Log.SomeMethod();
}

Structured sinks

Scope properties are available to structured sinks via LogScope.WriteToJson(Utf8JsonWriter), which writes each property as a JSON string property.

Performance

Scope enrichment has zero overhead on the dispatch hot path when no scopes are active. When scopes are active, a 512-byte stackalloc buffer is used to build the enriched message — no heap allocation.


Log Sampling and Rate Limiting

High-frequency log methods can be throttled at compile time using attributes on [LogMessage]. The generator emits lightweight guards that execute before any formatting or dispatch work.

Sampling

SampleRate = N emits every Nth log call. Uses a single Interlocked.Increment and modulo check:

// Only 1 in 10 heartbeat messages will be emitted
[LogMessage(LogLevel.Debug, "Heartbeat", SampleRate = 10)]
public static partial void Heartbeat();

The counter wraps naturally on int overflow. No lock, no allocation.

Rate limiting

MaxPerSecond = N caps throughput to N messages per second using a per-second time window:

// At most 100 messages per second
[LogMessage(LogLevel.Warning, "Request throttled", MaxPerSecond = 100)]
public static partial void RequestThrottled();

Window reset is a benign race — a few extra messages may slip through at the boundary. This is a logging rate limiter, not a security rate limiter.

Combining both

When both are set, SampleRate is applied first:

[LogMessage(LogLevel.Debug, "Tick", SampleRate = 5, MaxPerSecond = 50)]
public static partial void Tick();

The generator emits LSMITH007 as a warning when both are set on the same method.

Generated code

No guards are emitted when SampleRate is 0 or 1 and MaxPerSecond is 0. When active, the generator emits static counter fields and guard code at the top of the method body, after the IsEnabled check.


Dynamic Level Switching

Two opt-in mechanisms for adjusting log levels at runtime without calling Reconfigure.

Environment variable polling

LogManager.Initialize(config =>
{
    config.MinimumLevel = LogLevel.Debug;
    config.AddConsoleSink();
    config.WatchEnvironmentVariable("MY_LOG_LEVEL", pollInterval: TimeSpan.FromSeconds(5));
});

The monitor reads the environment variable on each poll tick and calls Enum.TryParse<LogLevel>. If the value changed, the minimum level is updated lock-free. Default poll interval is 5 seconds.

Config file watching

LogManager.Initialize(config =>
{
    config.MinimumLevel = LogLevel.Debug;
    config.AddConsoleSink();
    config.WatchConfigFile("logsmith.json");
});

The monitor uses FileSystemWatcher with a 500ms debounce. The JSON format:

{
    "MinimumLevel": "Warning",
    "CategoryOverrides": {
        "Noisy": "Error",
        "Network": "Debug"
    }
}

Both MinimumLevel and CategoryOverrides are optional. Parse errors are silently ignored (the file may be partially written).

Lifecycle

Monitors are created during Build() and stored in the config. When Reconfigure replaces the config, old monitors are disposed. Reset() also disposes monitors for test isolation.


Global Exception Handler

Explicit opt-in for capturing unhandled and unobserved task exceptions.

LogManager.CaptureUnhandledExceptions(ex =>
{
    Log.UnhandledException(ex);
});

This wires:

  • AppDomain.CurrentDomain.UnhandledException — captures unhandled exceptions on any thread.
  • TaskScheduler.UnobservedTaskException — captures exceptions from unawaited tasks.

The handler runs inside a try/catch — a failing handler cannot crash the process.

Observing task exceptions

By default, unobserved task exceptions are logged but not observed. To also call SetObserved() (preventing process termination in certain configurations):

LogManager.CaptureUnhandledExceptions(ex =>
{
    Log.UnhandledException(ex);
}, observeTaskExceptions: true);

Stopping capture

LogManager.StopCapturingUnhandledExceptions();

Reset() calls this automatically for test isolation.


Microsoft.Extensions.Logging Bridge

The Logsmith.Extensions.Logging package routes ILogger calls through Logsmith sinks. This enables Logsmith as the backend for libraries and frameworks that log through MEL.

Installation

<PackageReference Include="Logsmith" Version="1.0.0" />
<PackageReference Include="Logsmith.Extensions.Logging" Version="1.0.0" />

Registration

using Logsmith.Extensions.Logging;

// Initialize Logsmith sinks
LogManager.Initialize(config =>
{
    config.MinimumLevel = LogLevel.Debug;
    config.AddConsoleSink();
});

// Register as MEL provider
var services = new ServiceCollection();
services.AddLogging(builder =>
{
    builder.AddLogsmith();
});

Level mapping

MEL log levels map 1:1 to Logsmith levels — both enums use Trace=0 through Critical=5, None=6.

Scope integration

MEL's ILogger.BeginScope delegates to LogScope.Push. Scopes are shared between MEL and direct Logsmith usage:

// MEL scope
using (melLogger.BeginScope(new Dictionary<string, object> { ["CorrelationId"] = "corr-xyz" }))
{
    melLogger.LogInformation("From MEL");
    // Direct Logsmith call also sees the scope
    Log.ProcessingOrder("ORD-001", "Alice");
    // Both outputs include [CorrelationId=corr-xyz]
}

String-typed BeginScope states are stored as [Scope=value].

Category handling

ILoggerFactory.CreateLogger(categoryName) maps the category directly to the Logsmith LogEntry.Category field. Per-category minimum levels configured via SetMinimumLevel apply to MEL loggers.


Performance: in Parameters

For large value types, use the in modifier to pass by reference and avoid copying. The generator preserves in through the entire pipeline: method signature, state struct constructor, and state construction.

public struct SensorReading : IUtf8SpanFormattable
{
    public double Temperature, Humidity, Pressure;
    // ...
}

[LogCategory("Sensors")]
public static partial class SensorLog
{
    [LogMessage(LogLevel.Information, "Sensor reported {reading}")]
    public static partial void SensorData(in SensorReading reading);
}

// At the call site, the struct is passed by reference — no copy
SensorLog.SensorData(in reading);

Without in, the struct would be copied at each handoff (call site to method, method to state constructor). With in, only a single copy occurs when the value is stored into the state struct field, which is unavoidable since references cannot be stored in fields.

The in modifier is transparent at the call site for value types — existing callers that don't specify in explicitly continue to work (the compiler passes by reference automatically).


Custom Type Serialization

Text output

The generator selects the optimal formatting strategy for each parameter type at compile time, in this priority order:

  1. IUtf8SpanFormattable -- direct UTF8 write to Span<byte>, zero allocation.
  2. ISpanFormattable -- write to stack-allocated Span<char>, transcode to UTF8.
  3. IFormattable -- calls ToString(null, null).
  4. ToString() -- last resort.

For optimal performance, implement IUtf8SpanFormattable on your types:

public struct Mat3 : IUtf8SpanFormattable
{
    public float M00, M01, M02, M10, M11, M12, M20, M21, M22;

    public bool TryFormat(Span<byte> utf8Destination, out int bytesWritten,
        ReadOnlySpan<char> format, IFormatProvider? provider)
    {
        // Write directly to UTF8 buffer, no intermediate strings
    }
}

Structured output

For the JSON property path, implement ILogStructurable:

public interface ILogStructurable
{
    void WriteStructured(Utf8JsonWriter writer);
}

public struct Mat3 : IUtf8SpanFormattable, ILogStructurable
{
    public void WriteStructured(Utf8JsonWriter writer)
    {
        writer.WriteStartArray();
        writer.WriteNumberValue(M00);
        writer.WriteNumberValue(M01);
        // ... remaining values
        writer.WriteEndArray();
    }
}

The generator detects these interfaces at compile time and emits the appropriate call. No runtime type checks.


Nullable Parameters

The generator handles nullable types with compile-time null guards:

[LogMessage(LogLevel.Debug, "Result: {value}, User: {userName}")]
public static partial void LogResult(int? value, string? userName);

For nullable value types (int?, double?), the generator emits a HasValue check and writes "null" as a UTF8 literal when empty. For nullable reference types, it emits a null reference check. The structured path uses Utf8JsonWriter.WriteNull() for null values.


Caller Information

Add [CallerFilePath], [CallerLineNumber], or [CallerMemberName] parameters in any order and in any combination. The generator identifies them by attribute, not by position, and excludes them from message template matching.

[LogMessage(LogLevel.Error, "Operation failed: {reason}")]
public static partial void OperationFailed(
    string reason,
    Exception ex,
    [CallerFilePath] string file = "",
    [CallerLineNumber] int line = 0,
    [CallerMemberName] string member = "");

The C# compiler fills these in at each call site with interned string literals and integer constants. No runtime cost. The values are attached to LogEntry for sinks to include in their output.

Caller parameters can appear before, after, or interleaved with message parameters:

// All valid
public static partial void Foo(int x, [CallerLineNumber] int line = 0);
public static partial void Foo([CallerMemberName] string member = "", int x = 0);
public static partial void Foo([CallerFilePath] string file = "", int x = 0, [CallerLineNumber] int line = 0);

Exception Handling

If a parameter's type is Exception (or a derived type), the generator treats it as a special attachment rather than a message template value. It is stored on LogEntry.Exception and not interpolated into the text output.

[LogMessage(LogLevel.Error, "Request to {endpoint} failed with status {statusCode}")]
public static partial void RequestFailed(string endpoint, int statusCode, Exception ex);
// Text output: "Request to /api/users failed with status 500"
// Exception attached separately in LogEntry.Exception for sinks to render

Explicit Sink Parameter

By default, log methods dispatch through the global LogManager. To route to a specific sink, add an ILogSink parameter. The generator detects it by type and uses it directly instead of the global dispatch.

[LogMessage(LogLevel.Debug, "Test event: {value}")]
public static partial void TestEvent(ILogSink sink, int value);

This is useful for testing with a RecordingSink or for routing specific log paths to dedicated sinks without configuring category filters.


Multi-Project Solutions

Single project or standalone application

Reference Logsmith or Logsmith.Generator alone. All types are available within the project, either as public types from the runtime library or as generated internal types.

Multiple projects sharing log definitions

Reference Logsmith in every project. The runtime package includes the source generator as a bundled analyzer. All projects share the same public types (LogLevel, ILogSink, LogEntry, etc.) and can define their own log method classes.

MyApp.sln
  MyApp.Core/          --> references Logsmith (defines RenderLog, AudioLog)
  MyApp.Networking/    --> references Logsmith (defines NetworkLog)
  MyApp.Host/          --> references Logsmith (initializes LogManager, references Core + Networking)

The generator detects whether the Logsmith assembly is present in the compilation's references. If present, it emits only the partial method bodies and uses the public types from the assembly. If absent, it emits the full infrastructure as internal types.


Compile-Time Diagnostics

The generator produces the following diagnostics:

Code Severity Description
LSMITH001 Error Placeholder {name} in message template has no matching parameter.
LSMITH002 Warning Parameter is not referenced in the message template and is not a special type (Exception, caller info, ILogSink).
LSMITH003 Error Log method must be static partial in a partial class.
LSMITH004 Error Parameter type does not implement IUtf8SpanFormattable, ISpanFormattable, IFormattable, or ToString().
LSMITH005 Warning Parameter has a [Caller*] attribute and also appears in the message template. Caller attribute takes priority.
LSMITH006 Warning :json format specifier on primitive type is unnecessary — prefer default formatting.
LSMITH007 Warning Both SampleRate and MaxPerSecond are set on the same method. SampleRate is applied first.

Extending Logsmith

Custom sinks

Implement ILogSink for text output or IStructuredLogSink for structured property access:

public sealed class CustomSink : ILogSink
{
    public bool IsEnabled(LogLevel level) => level >= LogLevel.Warning;

    public void Write(in LogEntry entry, ReadOnlySpan<byte> utf8Message)
    {
        // Write the formatted message to your target
    }

    public void Dispose() { }
}

Register at initialization:

Log.Initialize(config =>
{
    config.AddSink(new CustomSink());
});

Sink base classes

TextLogSink and BufferedLogSink provide common patterns. BufferedLogSink uses a Channel<T>-based async queue so the calling thread never blocks on I/O:

public sealed class MyNetworkSink : BufferedLogSink
{
    protected override void Flush(in LogEntry entry, ReadOnlySpan<byte> utf8Message)
    {
        // Called on background thread, safe to do I/O
    }
}

Testing

Use RecordingSink to capture log entries for assertions. No mocking frameworks required.

[TestFixture]
public class RenderLogTests
{
    private RecordingSink _sink;

    [SetUp]
    public void Setup()
    {
        _sink = new RecordingSink();
        Log.Initialize(config =>
        {
            config.MinimumLevel = LogLevel.Trace;
            config.AddSink(_sink);
        });
    }

    [Test]
    public void DrawCallCompleted_EmitsCorrectEntry()
    {
        RenderLog.DrawCallCompleted(42, 1.5);

        Assert.That(_sink.Entries, Has.Count.EqualTo(1));
        Assert.That(_sink.Entries[0].Level, Is.EqualTo(LogLevel.Debug));
        Assert.That(_sink.Entries[0].Category, Is.EqualTo("Renderer"));
        Assert.That(_sink.Entries[0].GetText(), Does.Contain("Draw call 42"));
        Assert.That(_sink.Entries[0].GetText(), Does.Contain("1.5ms"));
    }

    [Test]
    public void ShaderFailed_AttachesException()
    {
        var ex = new InvalidOperationException("compile error");
        RenderLog.ShaderFailed("MyShader", ex);

        Assert.That(_sink.Entries, Has.Count.EqualTo(1));
        Assert.That(_sink.Entries[0].Exception, Is.SameAs(ex));
        Assert.That(_sink.Entries[0].GetText(), Does.Not.Contain("InvalidOperationException"));
    }

    [TearDown]
    public void TearDown()
    {
        _sink.Dispose();
    }
}

Testing with explicit sink parameter

For isolated tests that do not touch global state:

[Test]
public void ExplicitSink_ReceivesEntry()
{
    var sink = new RecordingSink();

    // Uses the explicit sink overload, bypasses LogManager
    NetworkLog.ConnectionEstablished(sink, "10.0.0.1:8080", 12.5);

    Assert.That(sink.Entries, Has.Count.EqualTo(1));
}

Configuration Reference

LogManager initialization

Log.Initialize(config =>
{
    // Global minimum level (default: Information)
    config.MinimumLevel = LogLevel.Debug;

    // Per-category minimum level override
    config.SetMinimumLevel("Renderer", LogLevel.Trace);         // by string
    config.SetMinimumLevel<NetworkLog>(LogLevel.Warning);       // by type (recommended)

    // Internal error handler for sink exceptions
    config.InternalErrorHandler = ex => Console.Error.WriteLine($"Logging error: {ex}");

    // Add sinks (all accept optional ILogFormatter parameter)
    config.AddConsoleSink();
    config.AddConsoleSink(colored: false, formatter: NullLogFormatter.Instance);
    config.AddFileSink("logs/app.log");
    config.AddFileSink("logs/app.log", rollingInterval: RollingInterval.Daily);
    config.AddFileSink("logs/app.log", rollingInterval: RollingInterval.Hourly,
                       maxFileSizeBytes: 50_000_000);
    config.AddFileSink("logs/app.log", shared: true);  // multi-process safe
    config.AddStreamSink(networkStream, leaveOpen: true);
    config.AddDebugSink();
    config.AddSink(new CustomSink());

    // Dynamic level switching (optional)
    config.WatchEnvironmentVariable("MY_LOG_LEVEL");
    config.WatchEnvironmentVariable("MY_LOG_LEVEL", pollInterval: TimeSpan.FromSeconds(10));
    config.WatchConfigFile("logsmith.json");
});

Runtime reconfiguration

LogManager.Reconfigure(config =>
{
    config.ClearSinks();
    config.MinimumLevel = LogLevel.Warning;
    config.AddConsoleSink();
});

The configuration object is immutable. Reconfiguration builds a new config and swaps it atomically via a volatile write. The hot path reads the config through a single volatile read with no locking. Active monitors from the previous config are disposed.

Global exception handler

LogManager.CaptureUnhandledExceptions(
    handler: ex => Log.UnhandledException(ex),
    observeTaskExceptions: false);   // true to call SetObserved()

LogManager.StopCapturingUnhandledExceptions();

MSBuild properties

<PropertyGroup>
    
    <LogsmithConditionalLevel>Debug</LogsmithConditionalLevel>
</PropertyGroup>

Architecture

Package structure

The Logsmith NuGet package contains the runtime library in lib/net10.0/ and the source generator in analyzers/dotnet/cs/. Referencing Logsmith provides both.

The Logsmith.Generator NuGet package contains only the source generator in analyzers/dotnet/cs/. It embeds the Logsmith runtime source files as resources. When the generator detects that the Logsmith assembly is not referenced, it emits these embedded sources as internal types. This ensures the standalone internal types are always identical to the public types in the runtime library.

Generated code

For each [LogMessage]-decorated partial method, the generator emits:

  • A public const string CategoryName field with the resolved category name.
  • A level-guard early return (if (!LogManager.IsEnabled(level, category)) return) with per-category filtering.
  • Stack-allocated UTF8 buffer and Utf8LogWriter construction.
  • Alternating literal UTF8 writes and typed WriteFormatted calls for each template segment.
  • A LogEntry construction with compile-time constants for category, event ID, and source location.
  • Dispatch to LogManager.Dispatch with both the text span and a static property-writing delegate for structured sinks.
  • [Conditional("DEBUG")] when the method's level falls at or below the configured threshold.

Parameter classification

The generator classifies each method parameter by inspecting its type and attributes:

Classification Detection Handling
Sink Type is ILogSink Used as dispatch target instead of LogManager
Exception Type is or derives from Exception Attached to LogEntry.Exception, excluded from message
CallerFile Has [CallerFilePath] Attached to LogEntry.CallerFile
CallerLine Has [CallerLineNumber] Attached to LogEntry.CallerLine
CallerMember Has [CallerMemberName] Attached to LogEntry.CallerMember
Message Everything else Matched to template placeholders, formatted to output

Classification is by attribute and type, not by parameter position. Parameters of any classification can appear in any order.

Performance characteristics

  • Hot path (logging enabled): One volatile read (config), one enum comparison (level), stack-allocated buffer, direct UTF8 writes, no heap allocation for value-type parameters.
  • Hot path (logging disabled): One volatile read, one enum comparison, return. No buffer allocation, no argument formatting.
  • Hot path (with scopes): One additional null check on LogScope.Current. When scopes are active, a 512-byte stack buffer copy for enrichment. When inactive, zero overhead.
  • Hot path (with sampling): One Interlocked.Increment + modulo check. When rate limiting is active, one additional Volatile.Read + Interlocked pair per call.
  • Conditional-stripped methods: Zero cost. Call site does not exist in the compiled IL. Arguments are not evaluated.
  • Config swap: Single volatile write. No lock contention. Subsequent reads on any thread see the new config.

License

This project is licensed under the MIT License.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on Logsmith:

Package Downloads
Logsmith.Extensions.Logging

Microsoft.Extensions.Logging bridge for Logsmith

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.3.2 33 2/20/2026
0.3.1 33 2/19/2026
0.3.0 32 2/19/2026
0.2.0 33 2/19/2026
0.1.3 40 2/18/2026
0.1.2 34 2/18/2026
0.1.1 33 2/18/2026
0.1.0 37 2/18/2026