Logsmith.Extensions.Logging
0.3.2
dotnet add package Logsmith.Extensions.Logging --version 0.3.2
NuGet\Install-Package Logsmith.Extensions.Logging -Version 0.3.2
<PackageReference Include="Logsmith.Extensions.Logging" Version="0.3.2" />
<PackageVersion Include="Logsmith.Extensions.Logging" Version="0.3.2" />
<PackageReference Include="Logsmith.Extensions.Logging" />
paket add Logsmith.Extensions.Logging --version 0.3.2
#r "nuget: Logsmith.Extensions.Logging, 0.3.2"
#:package Logsmith.Extensions.Logging@0.3.2
#addin nuget:?package=Logsmith.Extensions.Logging&version=0.3.2
#tool nuget:?package=Logsmith.Extensions.Logging&version=0.3.2
Logsmith 
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
- Why Logsmith Exists
- What Logsmith Is
- What Logsmith Is Not
- Comparison with Other Frameworks
- Benchmarks
- Features
- Installation
- Quick Start
- Declaring Log Methods
- Message Templates
- Log Levels and Conditional Compilation
- Sinks
- Log Formatting
- Format Specifiers
- Structured Output
- Scoped Context
- Log Sampling and Rate Limiting
- Dynamic Level Switching
- Global Exception Handler
- Microsoft.Extensions.Logging Bridge
- Performance:
inParameters - Custom Type Serialization
- Nullable Parameters
- Caller Information
- Exception Handling
- Explicit Sink Parameter
- Multi-Project Solutions
- Compile-Time Diagnostics
- Extending Logsmith
- Testing
- Configuration Reference
- Architecture
- License
Packages
| Name | NuGet | Description |
|---|---|---|
Logsmith |
Runtime library with public types, sinks, and the bundled source generator. Use this when multiple projects share log definitions. | |
Logsmith.Generator |
Source generator only. Emits all infrastructure as internal types with zero runtime dependency. | |
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
inparameters 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 optionalLogsmith.Extensions.Loggingbridge routes MELILoggercalls through Logsmith sinks, but MEL-native sink packages (Seq, Datadog, Application Insights, OpenTelemetry) expect MEL'sILoggerProviderecosystem. Logsmith defines its ownILogSinkcontract 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
Standard (recommended for most projects)
<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
inmodifier to pass large structs by reference (see Performance:inParameters).
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:
IUtf8SpanFormattable-- direct UTF8 write toSpan<byte>, zero allocation.ISpanFormattable-- write to stack-allocatedSpan<char>, transcode to UTF8.IFormattable-- callsToString(null, null).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 CategoryNamefield 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
Utf8LogWriterconstruction. - Alternating literal UTF8 writes and typed
WriteFormattedcalls for each template segment. - A
LogEntryconstruction with compile-time constants for category, event ID, and source location. - Dispatch to
LogManager.Dispatchwith 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 additionalVolatile.Read+Interlockedpair 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 | Versions 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. |
-
net10.0
- Logsmith (>= 0.3.2)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.0-preview.1.25080.5)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.