Logging
Quarry uses Logsmith in Abstraction mode for structured logging. In this mode, Logsmith source-generates all logging types -- ILogsmithLogger, LogEntry, LogLevel, LogsmithOutput, and per-category log classes -- directly into the Quarry.Logging namespace at compile time. No Logsmith DLL ships with Quarry and no Logsmith package reference is required in consumer projects.
This means logging is zero-dependency: Quarry internally calls generated static methods like QueryLog.SqlGenerated(opId, sql), and Logsmith's source generator turns those into UTF-8 message formatting and a dispatch to LogsmithOutput.Logger. If no logger is assigned, the calls are effectively no-ops gated behind a null check.
The ILogsmithLogger Interface
All Quarry log output flows through a single interface:
namespace Quarry.Logging;
public interface ILogsmithLogger
{
bool IsEnabled(LogLevel level, string category);
void Write(in LogEntry entry, ReadOnlySpan<byte> utf8Message);
}
IsEnabled is called before any message formatting. If it returns false, the log entry is skipped entirely -- no string allocation, no UTF-8 encoding. This is the primary mechanism for filtering by level or category.
Write receives a LogEntry (a read-only struct) and the pre-formatted message as a ReadOnlySpan<byte> in UTF-8. The LogEntry struct contains:
| Property | Type | Description |
|---|---|---|
Level |
LogLevel |
Severity of the entry (Trace through Error) |
Category |
string |
Log category (e.g. "Quarry.Query") |
Exception |
Exception? |
Attached exception, if any (e.g. on query failure) |
The message arrives as UTF-8 bytes rather than a string to avoid unnecessary allocations on the hot path. Convert with Encoding.UTF8.GetString(utf8Message) when needed.
Setup
Implement ILogsmithLogger and assign it to LogsmithOutput.Logger:
using Quarry.Logging;
LogsmithOutput.Logger = new ConsoleLogger();
sealed class ConsoleLogger : ILogsmithLogger
{
public bool IsEnabled(LogLevel level, string category) => level >= LogLevel.Debug;
public void Write(in LogEntry entry, ReadOnlySpan<byte> utf8Message)
{
Console.WriteLine($"[{entry.Level}] {entry.Category}: {Encoding.UTF8.GetString(utf8Message)}");
}
}
To disable logging, set LogsmithOutput.Logger = null (the default). LogsmithOutput.Logger is a process-wide singleton, so set it once at startup before any Quarry operations.
Log Levels
Quarry defines six log levels, matching the standard severity progression:
| Level | Typical use in Quarry |
|---|---|
Trace |
Parameter values bound to queries (Quarry.Parameters) |
Debug |
SQL generated, fetch/modification completion |
Information |
Connection opened/closed, migration progress |
Warning |
Slow query detection, migration cautionary notices |
Error |
Query or modification failures (with attached Exception) |
None |
Disables logging for the category |
Filtering with IsEnabled
The IsEnabled method receives both the level and the category, giving you fine-grained control. Quarry calls IsEnabled before every log operation, so returning false skips all formatting work.
sealed class FilteredLogger : ILogsmithLogger
{
public bool IsEnabled(LogLevel level, string category)
{
// Suppress noisy parameter logging in production
if (category == "Quarry.Parameters")
return false;
// Only show warnings and above for query logs
if (category == "Quarry.Query")
return level >= LogLevel.Warning;
// Default: Information and above
return level >= LogLevel.Information;
}
public void Write(in LogEntry entry, ReadOnlySpan<byte> utf8Message)
{
Console.WriteLine(Encoding.UTF8.GetString(utf8Message));
}
}
Log Categories
| Category | Default Level | What it logs |
|---|---|---|
Quarry.Connection |
Information | Connection opened/closed |
Quarry.Query |
Debug | SQL generated, fetch completion (row count + elapsed time), scalar results |
Quarry.Modify |
Debug | SQL generated, modification completion (operation + row count + elapsed time) |
Quarry.RawSql |
Debug | SQL generated, fetch/non-query/scalar completion |
Quarry.Parameters |
Trace | Parameter values bound to queries (@p0 = value) |
Quarry.Execution |
Warning | Slow query detection (elapsed time + SQL) |
Quarry.Migration |
Information | Migration applying/applied/rolled back, dry run, SQL generated |
Integrating with Microsoft.Extensions.Logging
Bridge Quarry's logging into ILoggerFactory so that Quarry log entries flow through the same pipeline as the rest of your application (console, Serilog sinks, Application Insights, etc.):
using System.Text;
using Quarry.Logging;
using MsLogLevel = Microsoft.Extensions.Logging.LogLevel;
public sealed class LogsmithBridge(ILoggerFactory loggerFactory) : ILogsmithLogger
{
public bool IsEnabled(Quarry.Logging.LogLevel level, string category)
{
var logger = loggerFactory.CreateLogger(category);
return logger.IsEnabled(MapLevel(level));
}
public void Write(in LogEntry entry, ReadOnlySpan<byte> utf8Message)
{
var logger = loggerFactory.CreateLogger(entry.Category);
var msLevel = MapLevel(entry.Level);
if (!logger.IsEnabled(msLevel))
return;
var message = Encoding.UTF8.GetString(utf8Message);
logger.Log(msLevel, entry.Exception, "{Message}", message);
}
private static MsLogLevel MapLevel(Quarry.Logging.LogLevel level) => level switch
{
Quarry.Logging.LogLevel.Trace => MsLogLevel.Trace,
Quarry.Logging.LogLevel.Debug => MsLogLevel.Debug,
Quarry.Logging.LogLevel.Information => MsLogLevel.Information,
Quarry.Logging.LogLevel.Warning => MsLogLevel.Warning,
Quarry.Logging.LogLevel.Error => MsLogLevel.Error,
_ => MsLogLevel.None,
};
}
Wire it up at startup:
var app = builder.Build();
var loggerFactory = app.Services.GetRequiredService<ILoggerFactory>();
LogsmithOutput.Logger = new LogsmithBridge(loggerFactory);
Once connected, Quarry categories appear as standard Microsoft.Extensions.Logging categories. You can configure their minimum levels through appsettings.json:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Quarry.Query": "Debug",
"Quarry.Parameters": "Warning",
"Quarry.Execution": "Warning"
}
}
}
Serilog Bridge
If you use Serilog directly (without Microsoft.Extensions.Logging), the bridge is similar:
using System.Text;
using Quarry.Logging;
using Serilog;
using Serilog.Events;
public sealed class SerilogBridge(Serilog.ILogger logger) : ILogsmithLogger
{
public bool IsEnabled(Quarry.Logging.LogLevel level, string category)
=> logger.ForContext("Category", category).IsEnabled(MapLevel(level));
public void Write(in LogEntry entry, ReadOnlySpan<byte> utf8Message)
{
var contextLogger = logger.ForContext("Category", entry.Category);
var serilogLevel = MapLevel(entry.Level);
if (!contextLogger.IsEnabled(serilogLevel))
return;
var message = Encoding.UTF8.GetString(utf8Message);
if (entry.Exception is { } ex)
contextLogger.Write(serilogLevel, ex, "{Message}", message);
else
contextLogger.Write(serilogLevel, "{Message}", message);
}
private static LogEventLevel MapLevel(Quarry.Logging.LogLevel level) => level switch
{
Quarry.Logging.LogLevel.Trace => LogEventLevel.Verbose,
Quarry.Logging.LogLevel.Debug => LogEventLevel.Debug,
Quarry.Logging.LogLevel.Information => LogEventLevel.Information,
Quarry.Logging.LogLevel.Warning => LogEventLevel.Warning,
Quarry.Logging.LogLevel.Error => LogEventLevel.Error,
_ => LogEventLevel.Fatal,
};
}
Operation Correlation
Every query and modification is assigned a monotonically increasing operation ID (opId) via OpId.Next(). All log entries from the same operation -- SQL generation, parameter binding, completion, and slow query warnings -- share the same opId, which appears as a [N] prefix in the log message.
This enables you to correlate related log entries even when multiple queries execute concurrently. For example, a single ExecuteFetchAllAsync call with a parameterized WHERE clause produces:
[Quarry.Query] [42] SQL: SELECT "UserId", "UserName" FROM "users" WHERE "UserId" = @p0
[Quarry.Parameters] [42] @p0 = 1
[Quarry.Query] [42] Fetched 1 rows in 0.3ms
All three entries share opId 42. A second query running concurrently would get a different opId (e.g. 43), so you can filter or group entries by their [N] prefix to isolate a single operation:
[Quarry.Query] [42] SQL: SELECT "UserId", "UserName" FROM "users" WHERE "UserId" = @p0
[Quarry.Query] [43] SQL: SELECT "OrderId", "Total" FROM "orders" WHERE "UserId" = @p0
[Quarry.Parameters] [42] @p0 = 1
[Quarry.Parameters] [43] @p0 = 5
[Quarry.Query] [42] Fetched 1 rows in 0.3ms
[Quarry.Query] [43] Fetched 3 rows in 0.5ms
Even with interleaved output, the opId prefix makes it straightforward to reconstruct the full timeline for operation 42 vs 43.
Slow Query Detection
db.SlowQueryThreshold = TimeSpan.FromSeconds(1); // default: 500ms
db.SlowQueryThreshold = null; // disable
When a query's elapsed time exceeds the threshold, a Warning-level entry is emitted on the Quarry.Execution category with the elapsed time and the SQL text:
[Quarry.Execution] [42] Slow query (1205ms): SELECT "UserId", "UserName" FROM "users" WHERE ...
Sensitive Parameter Redaction
Mark columns with the Sensitive() modifier in the schema to redact their parameter values in all log output. This prevents secrets, passwords, tokens, and other sensitive data from appearing in logs regardless of the configured log level.
public class WidgetSchema : Schema
{
public static string Table => "widgets";
public Key<Guid> WidgetId => ClientGenerated();
public Col<string> WidgetName => Length(100);
public Col<string> Secret => Length(200).Sensitive(); // redacted in logs
}
When a sensitive column is bound as a parameter, the generator emits a call to ParameterLog.BoundSensitive instead of ParameterLog.Bound. The actual value is never passed to the logging infrastructure:
[Quarry.Parameters] [42] @p0 = Gizmo
[Quarry.Parameters] [42] @p1 = [SENSITIVE]
The redaction applies to all operations -- queries, inserts, updates -- anywhere the sensitive column appears as a parameter.