RandomSkunk.StructuredLogging.Analyzers
0.10.0
dotnet add package RandomSkunk.StructuredLogging.Analyzers --version 0.10.0
NuGet\Install-Package RandomSkunk.StructuredLogging.Analyzers -Version 0.10.0
<PackageReference Include="RandomSkunk.StructuredLogging.Analyzers" Version="0.10.0"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
<PackageVersion Include="RandomSkunk.StructuredLogging.Analyzers" Version="0.10.0" />
<PackageReference Include="RandomSkunk.StructuredLogging.Analyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
paket add RandomSkunk.StructuredLogging.Analyzers --version 0.10.0
#r "nuget: RandomSkunk.StructuredLogging.Analyzers, 0.10.0"
#:package RandomSkunk.StructuredLogging.Analyzers@0.10.0
#addin nuget:?package=RandomSkunk.StructuredLogging.Analyzers&version=0.10.0
#tool nuget:?package=RandomSkunk.StructuredLogging.Analyzers&version=0.10.0
RandomSkunk.StructuredLogging
Modern structured logging extensions for .NET that separate human-readable messages from machine-readable attributes.
Microsoft.Extensions.Logging's LogInformation/LogDebug/LogWarning/etc. extension methods
force every structured property into the message template:
logger.LogInformation("User {UserId} logged in from {IpAddress}", userId, ipAddress);
That's fine until the message needs to read differently than the template dictates, or a property needs to be attached without cluttering the sentence. This library separates the two concerns: the message is a normal interpolated string, worded however you want, and structured properties are attached explicitly alongside it.
using RandomSkunk.StructuredLogging;
logger.Information($"User logged in", ("UserId", userId), ("IpAddress", ipAddress));
Install
dotnet add package RandomSkunk.StructuredLogging
Targets net8.0 and net10.0, and depends only on Microsoft.Extensions.Logging.Abstractions.
The basic call
One extension method per LogLevel — Trace, Debug, Information, Warning, Error,
Critical — plus a level-as-argument Write. The message parameter accepts a normal interpolated
string:
logger.Information($"User {userId} logged in");
logger.Write(LogLevel.Information, $"User {userId} logged in");
Each level method (and Write) has overloads for the leading EventId/Exception arguments —
both, either, or neither — always in this order: logger, [eventId], [exception], message,
...:
logger.Information(MyEventIds.UserLoggedIn, $"User {userId} logged in"); // EventId only
logger.Error(ex, $"Failed to process order {orderId}"); // Exception only
logger.Error(MyEventIds.OrderFailed, ex, $"Failed to process order {orderId}"); // both
You don't need to guard these calls with if (logger.IsEnabled(LogLevel.X)). The message
parameter is a custom [InterpolatedStringHandler], not a plain string — its constructor checks
IsEnabled before any interpolation hole is evaluated, so logger.Debug($"... {ExpensiveCall()}")
never calls ExpensiveCall() when Debug is disabled. A plain string argument (no interpolation)
is also accepted and always treated as enabled, so a hardcoded or precomputed message is never
penalized either.
Attaching structured properties
There are four ways to attach properties to a call, chosen by what you know at the call site.
1. Statically-known properties (up to 6)
Pass one (string Name, T Value) tuple argument per property, after the message. These generic
overloads avoid boxing the values until (and unless) a log provider actually reads them:
logger.Information($"Order processed", ("OrderId", orderId), ("Total", total));
logger.Warning(MyEventIds.SlowRequest, $"Slow request", ("Path", path), ("DurationMs", elapsedMs));
2. <PropertyName> format tags — capture a value that's also in the message
An interpolation hole's format can start with an "html-like" tag to append the value to the message text and capture its raw, unformatted value as a structured property under that name:
logger.Information($"User {userId:<UserId>} logged in from {ip:<IpAddress>}");
// message text: "User 42 logged in from 10.0.0.1"
// structured properties: UserId = 42 (int), IpAddress = <IPAddress instance>
logger.Debug($"[{ts:<Timestamp>HH:mm:ss}] tick");
// message text uses "HH:mm:ss" to format ts; the Timestamp property holds the raw DateTime
An empty tag (<>) strips itself out without capturing anything — use it when a real format
string happens to start with <: {value:<>therealformat}.
<@PropertyName> — Serilog-style destructured message text
A tag whose name starts with @ renders the value into the message text using Serilog-style
destructured formatting instead of ToString()/IFormattable formatting, while still capturing
the raw, undestructured value as the structured property (destructuring never changes what gets
captured — only how it's rendered into the message):
logger.Trace($"Item added to cart: {item:<@Item>}");
// message text: "Item added to cart: OrderItem { CartId: 123, ItemId: 456, Quantity: 1 }"
// structured properties: Item = <the raw OrderItem instance>
logger.Trace($"Item added to cart: {item:<@>}");
// same message text, but the empty destructuring tag doesn't capture a structured property
Rendering rules: objects render as TypeName { Prop1: Value1, Prop2: Value2 } (anonymous types
omit the type name); collections render as [item1, item2]; dictionaries render as
{ [key1]: value1, [key2]: value2 }; strings and chars are quoted, other scalars (numbers,
bool, enums, DateTime, Guid, etc.) render unquoted using invariant culture; null renders as
null. Nested objects/collections are capped at 10 levels deep and 10 items per
collection/dictionary (both shown as ... when exceeded), and a self-referencing object renders
<circular reference> instead of recursing forever. Any format text after a <@...> tag's
closing > is ignored, since destructured rendering fully replaces ordinary formatting.
3. Dynamic properties, or more than 6 — params array
logger.Information(
$"Order processed",
("OrderId", orderId),
("Total", total),
("Currency", currency),
("Tax", tax),
("Discount", discount),
("Shipping", shipping),
("CouponCode", coupon));
Every generic-tuple call in option 1 is really calling one specific overload per arity; once a
call needs more than 6 properties, or the set of properties is built at runtime, this overload
takes over with the same call shape — just boxed object? values.
4. Merging a pre-built collection with per-call properties
If you already have an IReadOnlyCollection<KeyValuePair<string, object?>> (say, a
Dictionary<string, object?> of ambient/scope properties), every shape above has a sibling
overload that accepts it as a leading parameter, right after logger (before eventId/exception
if present):
logger.Warning(scopeProperties, MyEventIds.SlowRequest, $"Slow request to {path:<Route>}", ("DurationMs", elapsedMs));
Properties end up in this order: the collection's entries, then tag-captured properties, then the trailing per-call properties argument(s).
All four options can be combined freely in a single call — tags in the message, static tuples after it, and (if needed) a leading collection — as long as the total structured property count stays within what the chosen overload supports.
Design goals
- Messages are just strings. No format-string parsing or caching at the framework level — a message can be as dynamic or as constant as you like without a performance penalty either way.
- Properties are explicit and typed. Statically-known properties are passed as generic tuples
and stay unboxed until a provider reads them; only the
params/collection overloads box. IsEnabledchecks are automatic and free. Expensive interpolation holes are never evaluated when the level is disabled, without writing a guard clause yourself.- No
IFormatProvidersurprises. Interpolated message text is formatted withCultureInfo.InvariantCulture, matching how most structured log sinks expect it.
Migrating from Microsoft.Extensions.Logging
// Before:
logger.LogInformation("User {UserId} logged in from {IpAddress}", userId, ipAddress);
// After — properties still shown in the message text:
logger.Information($"User {userId:<UserId>} logged in from {ipAddress:<IpAddress>}");
// After — message worded freely, properties attached separately:
logger.Information($"User logged in", ("UserId", userId), ("IpAddress", ipAddress));
using Microsoft.Extensions.Logging; // for LogLevel, EventId, ILogger, etc.
using RandomSkunk.StructuredLogging; // brings the Trace/Debug/.../Write extension methods into scope
Analyzers
dotnet add package RandomSkunk.StructuredLogging.Analyzers
RandomSkunk.StructuredLogging.Analyzers is a separate, optional package of Roslyn analyzers
that ship as a build-time-only dependency (it adds nothing to your published output). It's
independent of the main package — install it in any project where you'd like the analysis, even
one that doesn't reference RandomSkunk.StructuredLogging itself.
| ID | Severity | Description |
|---|---|---|
RSSL0001 |
Suggestion | Flags a call to one of Microsoft.Extensions.Logging.LoggerExtensions's Log/LogTrace/LogDebug/LogInformation/LogWarning/LogError/LogCritical extension methods and suggests the equivalent RandomSkunk.StructuredLogging extension method — see Migrating from Microsoft.Extensions.Logging above. |
RSSL0002 |
Silent | Marks an interpolation hole that uses the <PropertyName> tag format (e.g. {who:<Recipient>}, including the destructuring <@PropertyName> form) to capture a structured property. Silent by default — it exists to anchor code fixes that act on these holes, not to warn about anything. |
RSSL0003 |
Silent | Marks an interpolation hole that does not use the <PropertyName> tag format (e.g. {who}) to capture a structured property. Silent by default — it exists to anchor code fixes that act on these holes, not to warn about anything. |
RSSL0004 |
Silent | Marks a name/value tuple argument (e.g. ("UserId", userId)) passed at the end of a RandomSkunk.StructuredLogging extension method call to attach a structured property, when the name is a compile-time constant string (a literal, a constant concatenation, or an interpolated string whose holes are themselves constant strings). Silent by default — it exists to anchor code fixes that act on these arguments, not to warn about anything. |
RSSL0005 |
Silent | Marks a call to any of the RandomSkunk.StructuredLogging Trace/Debug/Information/Warning/Error/Critical/Write extension methods, regardless of overload. Its code fix rewrites the call to the roughly equivalent Microsoft.Extensions.Logging call (the inverse of RSSL0001), moving each structured property into a {PropertyName} message-template placeholder — since a hole that isn't already tagged with a name (including a bare <@> destructuring tag) gets one guessed from its expression (the same guess RSSL0003's fix uses), most calls convert cleanly. A call is left unconverted only when a name truly can't be pinned down: a hole whose expression isn't a simple identifier, a tuple argument with a dynamically-computed name, or the leading collection-parameter overload. Silent by default — it exists to anchor code fixes that act on these calls, not to warn about anything. |
Claude Code skill
The package ships a Claude Code skill file describing this
API. When you build a project that references RandomSkunk.StructuredLogging, the skill is
copied automatically to .claude/skills/randomskunk-structuredlogging/SKILL.md, so Claude Code
picks it up when writing or reviewing logging code in that project. Set
RandomSkunkStructuredLoggingSkipSkillInstall to true in your project to opt out.
License
Learn more about Target Frameworks and .NET Standard.
-
.NETStandard 2.0
- No dependencies.
NuGet packages (1)
Showing the top 1 NuGet packages that depend on RandomSkunk.StructuredLogging.Analyzers:
| Package | Downloads |
|---|---|
|
RandomSkunk.StructuredLogging
Modern structured logging extensions for .NET that separate human-readable messages from machine-readable attributes. Include contextual data (user IDs, correlation IDs, metrics) in logs without forcing them into message templates. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.10.0 | 38 | 7/15/2026 |