Declarative Observability
Instrumenting a .NET service with logs and traces usually means a lot of noisy, duplicative code: _logger.LogInformation calls wrapped in try/catch, using var activity = ActivitySource.StartActivity(...), manual SetTag / SetStatus / Dispose — in every method you care about.
ZibStack ships two attributes that handle all of that at compile time, so business methods stay pure:
[Log](fromZibStack.NET.Log) — entry/exit/exception logs with structured properties, zero allocation via compile-timeLoggerMessage.Defineinterceptors.[Trace](fromZibStack.NET.Aop) —System.Diagnostics.Activityspan per call, compatible with any OpenTelemetry exporter.
Both decorate methods or classes, compose with each other, and cost zero runtime reflection.
Install
Section titled “Install”dotnet add package ZibStack.NET.Log # [Log] + structured interpolated loggingdotnet add package ZibStack.NET.Aop # [Trace] + AOP runtimedotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol # pick your exporterdotnet add package OpenTelemetry.Extensions.HostingMinimum viable setup
Section titled “Minimum viable setup”Three code changes plus an attribute or two.
using ZibStack.NET.Aop;
var builder = WebApplication.CreateBuilder(args);
// (1) Register built-in aspect handlers ([Trace], [Retry], [Cache], [Metrics], ...).builder.Services.AddAop();
// (2) Wire OpenTelemetry tracing. '*' listens on every ActivitySource// created by [Trace] (one per decorated class by default).builder.Services.AddOpenTelemetry() .WithTracing(tracing => tracing .AddSource("*") .AddOtlpExporter());
var app = builder.Build();
// (3) Bridge DI into the aspect runtime. Must run once, before any// aspect-decorated method is invoked.app.Services.UseAop();
app.Run();using ZibStack.NET.Aop;using ZibStack.NET.Log;
[Log] // entry/exit/exception log on every public method[Trace] // Activity span on every public methodpublic class OrderService{ private readonly ILogger<OrderService> _logger; public OrderService(ILogger<OrderService> logger) => _logger = logger;
public async Task<Order> PlaceOrderAsync(int customerId, string product, int quantity) { _logger.LogInformation($"Processing order for customer {customerId}"); // ... business logic return new Order { /* … */ }; }}That’s the whole setup. Every call to PlaceOrderAsync now:
- Logs an entry line with all parameters (
Entering OrderService.PlaceOrderAsync(customerId: 42, product: "Widget", quantity: 3)) - Opens an
Activityspan namedPlaceOrderAsyncunder anActivitySourcenamedOrderService - Attaches parameters as span tags (
customerId=42,product=Widget,quantity=3) - Logs the interpolated-string message as structured (template
"Processing order for customer {customerId}"+ propertycustomerId=42) — not a flat string - Logs an exit line with elapsed time and return value on success, or an exception line on failure
- Closes the span with
Okstatus andelapsed_mstag, orErrorstatus + exception tags on failure
What gets generated
Section titled “What gets generated”Behind the scenes the ZibStack generators rewrite the class into something like:
// PSEUDO — this is the moral equivalent of what the generator emits.[InterceptsLocation(...)]public static async Task<Order> PlaceOrderAsync_Intercepted( this OrderService @this, int customerId, string product, int quantity){ // [Log] inline emitter var logger = AspectServiceProvider.Resolve<ILogger<OrderService>>(); __entryDelegate(logger, customerId, product, quantity, null); // cached LoggerMessage.Define<...> var sw = Stopwatch.StartNew();
// [Trace] runtime handler var traceHandler = AspectServiceProvider.Resolve<TraceHandler>(); var ctx = new AspectContext { ClassName = "OrderService", MethodName = "PlaceOrderAsync", /* … */ }; traceHandler.OnBefore(ctx);
try { var result = await @this.PlaceOrderAsync(customerId, product, quantity); sw.Stop(); ctx.ElapsedMilliseconds = sw.ElapsedMilliseconds; ctx.ReturnValue = result; traceHandler.OnAfter(ctx); __exitDelegate(logger, sw.ElapsedMilliseconds, result, null); return result; } catch (Exception ex) { sw.Stop(); ctx.ElapsedMilliseconds = sw.ElapsedMilliseconds; traceHandler.OnException(ctx, ex); __errorDelegate(logger, sw.ElapsedMilliseconds, ex); throw; }}Two details that matter:
[Log]is an “inline emitter” — the generator writes the logging code directly into the AOP interceptor with zero per-call allocation (one cachedLoggerMessage.Define<T1,T2,T3>delegate per decorated method).[Trace]is a “runtime handler” — the generator callsTraceHandler.OnBefore/OnAfter/OnExceptionthrough theIAspectHandlerinterface. Handler is a singleton registered byAddAop(), so dispatch cost is one virtual call.
You can stack more aspects beyond [Log] / [Trace] — write your own IAspectHandler and they all run in a single generated interceptor. See AOP → Custom Aspects for the recipe.
Not to be confused with interpolated-string logging. The
[Log]attribute path above rewrites the method definition to wrap each call with entry/exit/exception code. The interpolated-string structured-logging path (shown in the next section) rewrites individuallogger.LogXxx($"...")call sites inside the method body. Both use source-generated interceptors and both end up dispatching throughLoggerMessage.Define, but they target different things and can be used independently. A method can have[Log]without containing any interpolated-string logs, and vice versa.
Structured interpolated logging
Section titled “Structured interpolated logging”The biggest quality-of-life win in ZibStack.NET.Log isn’t [Log] itself — it’s that standard ILogger calls with interpolated strings become structured at compile time:
using ZibStack.NET.Log;
_logger.LogInformation($"Processing order for customer {customerId}");That looks like a plain flat string, but it isn’t. The mechanism is two layers stacked:
-
Extension-method shadowing.
ZibStack.NET.LogshipsLogInformation(this ILogger, [InterpolatedStringHandlerArgument("logger")] ref ZibLogInformationHandler)as an extension method. Onceusing ZibStack.NET.Log;is in scope, C# 11 overload resolution prefers this overload over Microsoft’sLogInformation(this ILogger, string, params object[])whenever the argument is an interpolated string. The handler itself is aref structwith typed slots (long,double,decimal,string,object?) that store each interpolation argument without boxing, and it captures structured property names via[CallerArgumentExpression]. The handler’s constructor checkslogger.IsEnabled(…)and writesout bool shouldAppend— if the level is disabled, the compiler skips everyAppendFormattedcall, so$"{ExpensiveToString()}"is never evaluated. That gives you lazy eval, zero boxing, and structured properties from the handler alone, before any source generator runs. -
Source-generated interceptor. The ZibStack.NET.Log generator scans every
logger.LogXxx($"...")call site and emits a per-call-site[InterceptsLocation]interceptor that dispatches through a cachedLoggerMessage.Define<T1, T2, T3>delegate. Conceptually the generated code is:// One cached delegate per call site, allocated at static init:private static readonly Action<ILogger, int, Exception?> __logProcessingOrder =LoggerMessage.Define<int>(LogLevel.Information,new EventId(1, "ProcessingOrder"),"Processing order for customer {customerId}");// The interceptor your original call site is rewritten to:if (!handler.IsEnabled) return;__logProcessingOrder(logger, (int)handler.L0, null);The template is parsed exactly once by
LoggerMessage.Defineat static init — not once per call like Microsoft’s default path. Combined with the handler’s typed slots (zero boxing) andshouldAppend(lazy eval), the result is ~5× faster than Microsoft’sLogInformation("template", args)with zero allocation in both enabled and disabled paths.
Result: structured properties (customerId=42 as an indexed field in Seq / Elastic / App Insights), lazy evaluation when the level is disabled (~0.4 ns for disabled LogDebug), and zero allocation per call.
See Log → In-depth: how LogInformation($"...") actually works for the full breakdown — including why you can’t do it with the handler alone, and what each layer contributes independently.
This works with every LogXxx method — LogTrace, LogDebug, LogInformation, LogWarning, LogError, LogCritical — and all their overloads (with Exception, with EventId, etc.).
“But doesn’t
CA2254tell me not to do this?” Yes, the built-in Roslyn analyzerCA2254: Template should be a static expressionwarns againstLogInformation($"...")precisely because Microsoft’s own overloads turn the interpolated string into a flat runtime-formatted message. ZibStack.NET.Log’s shadowing extension methods invert that: the interpolated-string handler preserves the template, and the source generator rewrites the call. It’s safe to suppressCA2254in projects that referenceZibStack.NET.Log— or keep it on if you want an explicit reminder to stay on the structured path.
PII and sensitive data — [Sensitive] / [NoLog]
Section titled “PII and sensitive data — [Sensitive] / [NoLog]”Parameters can be marked so neither [Log] nor [Trace] leaks them into logs or span tags:
using ZibStack.NET.Log;
[Log] [Trace]public Order PlaceOrder( int customerId, [Sensitive] string creditCard, // masked as *** everywhere [NoLog] byte[] rawPayload) // excluded entirely{ // …}Output:
info: OrderService[1] Entering OrderService.PlaceOrder(customerId: 42, creditCard: ***)Span tags:
code.namespace = OrderServicecode.function = PlaceOrdercustomerId = 42creditCard = ***# rawPayload is not in the tag list at allReturn-value masking. If your method returns an object containing PII, decorate the property on the type:
public class Order{ public int Id { get; set; } public decimal Total { get; set; }
[Sensitive] public string CustomerEmail { get; set; } = "";}When [Log] serializes the return value for the exit log line, CustomerEmail is replaced with ***. Works with ObjectLogMode.Destructure (default) and ObjectLogMode.Json.
Exporter setup by backend
Section titled “Exporter setup by backend”OTLP / Tempo / any standard collector
Section titled “OTLP / Tempo / any standard collector”builder.Services.AddOpenTelemetry() .WithTracing(tracing => tracing .AddSource("*") // every class decorated with [Trace] .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("my-api")) .AddOtlpExporter(o => { o.Endpoint = new Uri("http://localhost:4317"); o.Protocol = OtlpExportProtocol.Grpc; }));Jaeger (native OTLP since Jaeger 1.35+)
Section titled “Jaeger (native OTLP since Jaeger 1.35+)”Same OTLP setup as above, just point at Jaeger’s OTLP endpoint (default http://localhost:4317). Jaeger UI shows the spans without any extra config.
Seq (logs + traces in one place)
Section titled “Seq (logs + traces in one place)”dotnet add package Seq.Extensions.Loggingdotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocolbuilder.Services.AddLogging(logging => logging.AddSeq("http://localhost:5341"));
builder.Services.AddOpenTelemetry() .WithTracing(tracing => tracing .AddSource("*") .AddOtlpExporter(o => o.Endpoint = new Uri("http://localhost:5341/ingest/otlp")));Structured logs and traces from [Log] / [Trace] flow into the same Seq instance. Bonus: Seq’s query language lets you pivot on structured properties directly — customerId == 42 and @Level == 'Error' finds every failed order for customer 42 across all services.
Azure Application Insights
Section titled “Azure Application Insights”dotnet add package Azure.Monitor.OpenTelemetry.AspNetCorebuilder.Services.AddOpenTelemetry() .UseAzureMonitor(o => o.ConnectionString = "…");// AddSource("*") is still needed if you want to listen to ZibStack activity sourcesEvery [Trace]-decorated method becomes a “dependency” in App Insights with the class name as the operation.
Tuning [Trace]
Section titled “Tuning [Trace]”The default [Trace] opens an Activity named after the method, under an ActivitySource named after the class. Override both when needed:
// Group spans under a logical service name instead of the class[Trace(SourceName = "checkout.orders")]public async Task PlaceOrderAsync(Order order) { … }
// Override the operation name (e.g. for RED metrics aggregation)[Trace(OperationName = "orders.place")]public async Task PlaceOrderAsync(Order order) { … }
// Skip parameter tagging on hot paths or wide signatures[Trace(IncludeParameters = false)]public IEnumerable<Row> ScanAll(HugeFilter filter) { … }If you used SourceName = "checkout.orders", adjust your exporter listener:
tracing.AddSource("checkout.orders") // explicit instead of "*"Recipes
Section titled “Recipes”Trace only the slow paths
Section titled “Trace only the slow paths”public class OrderService{ [Log] public Order Validate(Order order) { /* fast, log-only */ }
[Log] [Trace] public async Task<Order> PersistAsync(Order order) { /* slow, worth a span */ }}Mix and match — [Log] and [Trace] are independent.
Built-in [Metrics] — RED metrics alongside traces
Section titled “Built-in [Metrics] — RED metrics alongside traces”[Trace] produces spans. For RED metrics (rate/errors/duration), add the built-in [Metrics] — already registered by AddAop():
[Log] [Trace] [Metrics]public async Task<Order> PlaceOrderAsync(Order o) { ... }This emits three System.Diagnostics.Metrics instruments under the ZibStack.Aop meter:
aop.method.call.count(Counter) — with tagsaop.class,aop.methodaop.method.call.duration(Histogram, ms) — same tagsaop.method.call.errors(Counter) — same tags
Wire to OpenTelemetry:
builder.Services.AddOpenTelemetry() .WithTracing(tracing => tracing.AddSource("*").AddOtlpExporter()) .WithMetrics(metrics => metrics.AddMeter("ZibStack.Aop").AddOtlpExporter());All three aspects ([Log], [Trace], [Metrics]) run in a single generated interceptor — no nesting overhead, no reflection.
Other built-in aspects
Section titled “Other built-in aspects”AddAop() also registers [Retry], [Cache], [Timeout], and [Authorize]. See AOP — Built-in Aspects for full reference.
Quiet by default, strict on opt-in
Section titled “Quiet by default, strict on opt-in”ZibStack.NET.Log doesn’t inject a global using and emits ZLOG002 at Info severity, so installing the package doesn’t mutate your existing call sites. When you’re ready to migrate legacy LogInformation("template", arg) calls to the structured interpolated form, flip strict mode:
<PropertyGroup> <ZibLogStrict>true</ZibLogStrict></PropertyGroup>That sets ZibLogEmitGlobalUsing=true and raises ZLOG002 from Info to Warning via a bundled .editorconfig. See Log → Configuration for individual toggles and per-file severity overrides.
Related reference
Section titled “Related reference”- Log — Structured Logging — full
[Log]attribute reference, interpolated-string internals, benchmarks - AOP — Aspects &
[Trace]— built-in[Trace]reference +IAspectHandler/IAroundAspectHandlercontract - Full CRUD with SQLite — the integrated setup used in the §5 observability section of that guide