BrandGhost
NLog Layout Renderers and Structured Logging in C#

NLog Layout Renderers and Structured Logging in C#

NLog layout renderers are the building blocks that format every log message before it reaches its target. They transform raw log event data -- timestamps, log levels, exception details, HTTP request context, custom properties -- into structured, queryable log output.

Understanding layout renderers is what separates a useful log pipeline from one that produces walls of unstructured text. This article covers the most important built-in layout renderers, how structured logging with message templates works in NLog, how to attach correlation IDs using MDC (Mapped Diagnostic Context), and how to write a custom renderer when the built-ins don't cover your needs.

Layout renderers are the building blocks of NLog's output formatting system -- they slot into target layouts to inject contextual values, structured data, and environmental metadata into every log event.

What Are Layout Renderers?

A layout renderer is a ${...} expression in an NLog layout string. When NLog writes a log event, it evaluates each renderer in the layout and substitutes its output into the final string.

The layout string on a File target:

${longdate}|${uppercase:${level}}|${logger}|${message} ${exception:format=tostring}

Produces lines like:

2026-08-07 14:23:45.1234|INFO|OrderController|Processing order 42 
2026-08-07 14:23:45.5678|ERROR|OrderController|Order 42 not found System.Exception: ...

Renderers can be nested: ${uppercase:${level}} wraps the ${level} renderer in ${uppercase} to produce INFO instead of Info.

Core Layout Renderers

These are the layout renderers you'll use in almost every NLog configuration. They cover the essentials -- timestamp, log level, logger name, message text, and exception detail -- and together they form the baseline for any well-structured log layout.

Date and Time

Timestamp renderers vary by precision and timezone. For distributed systems, prefer UTC renderers to avoid timezone-related correlation problems when logs are written from multiple servers in different regions. (Local time may be required for compliance or auditing in some regulated environments.)

Renderer Output Example Notes
${longdate} 2026-08-07 14:23:45.1234 Local time with milliseconds
${shortdate} 2026-08-07 Local date only. Useful in file names.
${time} 14:23:45.1234 Local time only
${ticks} 638234567451234567 High-resolution .NET ticks
${date:universalTime=true:format=o} 2026-08-07T14:23:45.1234Z ISO 8601 UTC

For log aggregation pipelines that ingest across time zones, prefer UTC: ${date:universalTime=true:format=o}.

Level and Logger

The ${logger} renderer outputs the category name passed to ILogger<T> -- which is the fully qualified type name by default. Use shortName=true to strip the namespace for console output where brevity matters, while keeping the full name in file logs for precise filtering.

Renderer Output Example Notes
${level} Info Default mixed case
${level:uppercase=true} INFO Uppercase
${level:truncate=4:uppercase=true} INFO, WARN, ERRO Padded 4-char codes
${logger} MyApp.Controllers.OrderController Full namespace + class
${logger:shortName=true} OrderController Class name only
${callsite} MyApp.Controllers.OrderController.ProcessOrder Includes method name

Message and Exception

The exception renderer supports a format parameter that controls which parts of the exception are included. In production, always include the full stack trace in at least one target -- the information needed to diagnose a problem is often buried in inner exceptions or in specific stack frames.

Renderer Output
${message} The log message string (with template parameters substituted)
${exception} Exception type and message (default)
${exception:format=tostring} Full exception with stack trace
${exception:format=message,type,stacktrace} Custom parts
${exception:format=shorttype} Just the exception type name

For file and database targets, use ${exception:format=tostring} to capture the full stack trace. For console output where readability matters more, use ${exception:format=message} or ${exception:format=shorttype,message}.

Process and Environment

These renderers expose runtime context about where the log event originated. They're especially useful in containerized and multi-instance deployments where the same log aggregation pipeline collects from many processes -- including ${machinename} or ${processid} lets you filter down to a specific instance when diagnosing a production issue.

Renderer Output
${processname} Process name
${processid} Process ID (PID)
${machinename} Hostname
${environment:ASPNETCORE_ENVIRONMENT} Environment variable value
${configsetting:item=SomeSetting} Value from IConfiguration (appsettings.json)
${basedir} Application base directory

${configsetting} is particularly useful in target definitions -- it lets you read connection strings, API keys, and other config values from appsettings.json without hardcoding them in nlog.config.

ASP.NET Core Renderers

These are only available when using NLog.Web.AspNetCore:

Renderer Output
${aspnet-request-url} Full request URL
${aspnet-request-method} HTTP method (GET, POST, etc.)
${aspnet-mvc-action} Controller action name
${aspnet-mvc-controller} Controller name
${aspnet-TraceIdentifier} ASP.NET Core trace ID
${aspnet-user-identity} Authenticated user identity name
${aspnet-request-ip} Client IP address

These renderers read from the current HttpContext. They return empty strings when called outside an HTTP request context (background services, Worker Services).

Structured Logging with Message Templates

The most important aspect of modern logging is structured logging -- capturing log data as named properties rather than concatenated strings.

Message Templates (the Right Way)

// ✅ Structured: OrderId is a named property on the log event
// Note: message template placeholders use {Name:format}, not ${Name}
_logger.LogInformation("Order {OrderId} placed by {CustomerId} for {Amount:F2}",
    order.Id, order.CustomerId, order.TotalAmount);

// ❌ Unstructured: everything collapses into a string
_logger.LogInformation($"Order {order.Id} placed by {order.CustomerId} for ${order.TotalAmount:F2}");

With message templates, OrderId, CustomerId, and Amount become separate named fields on the LogEventInfo object. Log aggregation tools (Seq, Elasticsearch, Grafana Loki) can filter, aggregate, and chart these as first-class data -- not parsed substrings.

Accessing Properties in Layout Renderers

Use ${event-properties:item=PropertyName} to include a structured property in a layout:

${longdate}|${level:uppercase=true}|${message}|OrderId=${event-properties:item=OrderId}

For a layout that captures all structured properties in a tab-separated format:

${longdate}|${level:uppercase=true}|${message}|${all-event-properties}

${all-event-properties} outputs all named properties from the log event. It's useful for capturing structured data in plain-text file targets without writing a layout renderer per property.

JsonLayout for Structured Output

When your log target is a JSON-consuming system (Elasticsearch, Splunk, Datadog, Loki), use JsonLayout instead of a text layout:

<target xsi:type="File"
        name="structuredFile"
        fileName="${basedir}/logs/structured-${shortdate}.json">
  <layout xsi:type="JsonLayout" includeAllProperties="true" excludeEmptyProperties="true">
    <attribute name="timestamp" layout="${date:universalTime=true:format=o}" />
    <attribute name="level" layout="${level:uppercase=true}" />
    <attribute name="logger" layout="${logger:shortName=true}" />
    <attribute name="message" layout="${message}" />
    <attribute name="exception" layout="${exception:format=tostring}" />
    <attribute name="traceId" layout="${aspnet-TraceIdentifier}" />
    <attribute name="environment" layout="${configsetting:item=ASPNETCORE_ENVIRONMENT}" />
  </layout>
</target>

This produces JSON log lines:

{"timestamp":"2026-08-07T14:23:45.1234Z","level":"INFO","logger":"OrderController","message":"Processing order 42","traceId":"0HMVG8D5R2NE7:00000001"}

includeAllProperties="true" appends all structured properties from the log event as additional JSON fields:

{
  "timestamp": "2026-08-07T14:23:45.1234Z",
  "level": "INFO",
  "logger": "OrderController",
  "message": "Processing order 42",
  "OrderId": 42,
  "CustomerId": "cust-789",
  "Amount": 149.99
}

This is the format that log aggregation dashboards (Kibana, Grafana) expect for structured queries and visualizations.

Mapped Diagnostic Context (MDC) for Correlation IDs

MDC lets you attach ambient properties to all log events in a logical execution context -- the C# equivalent of a thread-local storage bag for log metadata.

The most common use case is attaching a correlation ID to every log event within a request or operation, so you can filter your logs by that ID and see the complete trace.

Setting MDC Properties

using NLog;

// In middleware or at the start of a request
MappedDiagnosticsLogicalContext.Set("CorrelationId", correlationId);
MappedDiagnosticsLogicalContext.Set("UserId", userId);

// ... all log calls within this async context will include these values

// Clear when the operation completes
MappedDiagnosticsLogicalContext.Clear();

MappedDiagnosticsLogicalContext (MDLC) is async-safe: it flows with the logical call context through await calls, unlike MappedDiagnosticsContext (MDC) which is thread-local.

Including MDC Values in Layouts

Once you've set an MDLC value, reference it by name in any layout string. The renderer returns an empty string if the key isn't in scope, so it's safe to include in every layout even when some code paths don't set it.

${longdate}|${level:uppercase=true}|${mdlc:CorrelationId}|${logger:shortName=true}|${message}

Or with JsonLayout:

<layout xsi:type="JsonLayout" includeAllProperties="true">
  <attribute name="correlationId" layout="${mdlc:CorrelationId}" />
  <attribute name="userId" layout="${mdlc:UserId}" />
  ...
</layout>

Correlation ID Middleware (ASP.NET Core)

A practical way to populate MDLC values in a web application is a small middleware component that runs before your application logic. The middleware reads the incoming X-Correlation-ID header (or generates a new GUID), stores it in MDLC, and ensures every downstream log event includes it automatically.

public sealed class CorrelationIdMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<CorrelationIdMiddleware> _logger;

    public CorrelationIdMiddleware(
        RequestDelegate next,
        ILogger<CorrelationIdMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // Use X-Correlation-ID from incoming request, or generate a new one
        var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault()
            ?? Activity.Current?.TraceId.ToString()
            ?? Guid.NewGuid().ToString("N");

        // Set correlation ID in both the response header and the logging context
        context.Response.Headers["X-Correlation-ID"] = correlationId;
        MappedDiagnosticsLogicalContext.Set("CorrelationId", correlationId);

        try
        {
            await _next(context);
        }
        finally
        {
            MappedDiagnosticsLogicalContext.Remove("CorrelationId");
        }
    }
}

Register it early in your middleware pipeline in Program.cs:

app.UseMiddleware<CorrelationIdMiddleware>();

For more on building custom middleware, Custom Middleware in ASP.NET Core covers the patterns in depth.

Custom Layout Renderer

When built-in renderers don't produce exactly what you need, write your own. A custom renderer inherits from LayoutRenderer, overrides Append, and writes its value directly to a StringBuilder. The [LayoutRenderer("name")] attribute registers it with NLog's renderer factory, and after registration it becomes available in any layout string just like a built-in renderer.

using NLog;
using NLog.LayoutRenderers;

[LayoutRenderer("tenant-id")]
[ThreadSafe]
public sealed class TenantIdLayoutRenderer : LayoutRenderer
{
    // Optional: exposed as a parameter in the layout string
    // e.g., ${tenant-id:uppercase=true}
    public bool Uppercase { get; set; }

    protected override void Append(StringBuilder builder, LogEventInfo logEvent)
    {
        // Try to read TenantId from MDLC first, then from event properties
        var tenantId = MappedDiagnosticsLogicalContext.GetObject("TenantId") as string
            ?? logEvent.Properties.GetValueOrDefault("TenantId") as string
            ?? "unknown";

        builder.Append(Uppercase ? tenantId.ToUpperInvariant() : tenantId);
    }
}

Register before initializing NLog:

LayoutRenderer.Register<TenantIdLayoutRenderer>("tenant-id");

Use in layouts:

${longdate}|${tenant-id}|${logger:shortName=true}|${message}

Serilog's Destructuring Operator Equivalent

If you're familiar with Serilog, you may be wondering about the @ destructuring operator ({@User} to log the full object). NLog's equivalent is to serialize properties explicitly or use ${all-event-properties} with a JsonLayout.

For logging complex objects as structured data:

// NLog: pass as a property with a descriptive name
var orderDetails = new { Id = 42, Status = "Processing", Items = 3 };
_logger.LogInformation("Order details: {OrderDetails}",
    System.Text.Json.JsonSerializer.Serialize(orderDetails));

NLog has no @ destructuring operator like Serilog does. However, if you pair NLog with JsonLayout and configure serialization explicitly, NLog CAN write object-valued properties as structured JSON fields -- it just requires the configuration step rather than an inline operator. For the common case of a single complex object, serializing to a JSON string (as shown above) and logging it as a string property is the simplest portable approach.

Frequently Asked Questions

What is the difference between $

${message} outputs the formatted log message string -- the template with all named properties substituted in. ${all-event-properties} outputs the raw named properties from the log event as a key-value list, separate from the message. For structured logging pipelines, you often want both in your layout to capture the human-readable message and the machine-queryable properties independently.

What is the difference between MDC and MDLC in NLog?

MappedDiagnosticsContext (MDC) is thread-local -- values don't flow across threads when you use async/await. MappedDiagnosticsLogicalContext (MDLC) uses AsyncLocal<T> internally, so values flow correctly across async continuations. Always use MDLC for async ASP.NET Core code. MDC is only safe for synchronous, single-threaded code.

How do I log the full stack trace in NLog?

Use ${exception:format=tostring} in your layout. This calls Exception.ToString() on the logged exception, which includes the exception type, message, and full stack trace for the exception and all inner exceptions. For structured JSON output, add it as a separate attribute in JsonLayout so the stack trace is a distinct field rather than embedded in the message string.

Can I use different layout renderers for different targets?

Yes. Each target has its own layout (or <layout> child element for JsonLayout). You can use a verbose layout with full stack traces for the file target and a concise layout for the console target, both within the same NLog configuration.

What is JsonLayout and when should I use it?

JsonLayout is a NLog layout type that outputs log events as JSON objects rather than formatted text strings. Use it when your log target is a structured log aggregation system -- Elasticsearch, Splunk, Datadog, Seq, or any system that ingests JSON. With includeAllProperties="true", all named message template parameters appear as first-class JSON fields, enabling structured queries and aggregations.

How do I add a correlation ID to all log messages in ASP.NET Core?

Set the correlation ID using MappedDiagnosticsLogicalContext.Set("CorrelationId", id) in an early middleware. Include it in your layout using ${mdlc:CorrelationId}. Clear it in the middleware's finally block. For the ASP.NET Core request trace ID, ${aspnet-TraceIdentifier} reads it directly without needing MDLC.

How do I use configsetting to read appsettings.json values in layout renderers?

Use ${configsetting:item=Your:Config:Path}. The item parameter follows the same colon-separated key format as IConfiguration -- ${configsetting:item=ConnectionStrings.AppDb} reads ConnectionStrings.AppDb from appsettings.json. This works in target attributes and layout strings. The IConfiguration instance is registered automatically when you call builder.Host.UseNLog().

NLog Targets in .NET: File, Database, Console, and Custom

Deep dive into NLog targets in .NET. Learn how to configure File, Console, Database, Seq, and custom targets with real C# examples for ASP.NET Core and Worker Services.

NLog in .NET: Complete Guide to Flexible Logging

Master NLog in .NET with this complete guide. Learn targets, rules, layout renderers, structured logging, and performance optimization for C# applications.

Getting Started with NLog in ASP.NET Core

Learn how to set up NLog in ASP.NET Core step by step. Covers XML nlog.config and appsettings.json configuration, Program.cs setup, Worker Services, and ILogger usage.

An error has occurred. This application may no longer respond until reloaded. Reload