Skip to content

Migration From ILogger

Kieron Lanning edited this page Feb 8, 2026 · 1 revision

Migrating from Manual ILogger to Source Generator

This guide shows you how to migrate from traditional manual ILogger usage to the Purview Telemetry Source Generator's structured logging generation.

Why Migrate?

Benefits of Using the Source Generator:

  • Compile-time safety - typos and parameter mismatches caught at build time
  • Zero boilerplate - no manual LoggerMessage.Define or magic strings
  • Consistent structure - enforced logging patterns across your codebase
  • Performance - optimized code generation with minimal allocations
  • Testability - easy to mock and verify logging calls
  • Message templates - automatic message generation from method signatures
  • OpenTelemetry integration - built-in support for structured logging standards

Migration Examples

Example 1: Basic Logging

Before (Manual ILogger):

using Microsoft.Extensions.Logging;

public class OrderService
{
    private readonly ILogger<OrderService> _logger;

    public OrderService(ILogger<OrderService> logger)
    {
        _logger = logger;
    }

    public async Task ProcessOrderAsync(int orderId, string customerName)
    {
        _logger.LogInformation("Processing order {OrderId} for customer {CustomerName}", 
            orderId, customerName);

        try
        {
            // Process order...
            
            _logger.LogInformation("Order {OrderId} processed successfully", orderId);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to process order {OrderId}", orderId);
            throw;
        }
    }
}

After (Source Generator):

using Purview.Telemetry;

// Define telemetry interface
[Logger]
public interface IOrderServiceTelemetry
{
    [Info]
    void ProcessingOrder(int orderId, string customerName);

    [Info]
    void OrderProcessedSuccessfully(int orderId);

    [Error]
    void FailedToProcessOrder(Exception ex, int orderId);
}

// Register in DI
services.AddOrderServiceTelemetry();

// Use in your service
public class OrderService
{
    private readonly IOrderServiceTelemetry _telemetry;

    public OrderService(IOrderServiceTelemetry telemetry)
    {
        _telemetry = telemetry;
    }

    public async Task ProcessOrderAsync(int orderId, string customerName)
    {
        _telemetry.ProcessingOrder(orderId, customerName);

        try
        {
            // Process order...
            
            _telemetry.OrderProcessedSuccessfully(orderId);
        }
        catch (Exception ex)
        {
            _telemetry.FailedToProcessOrder(ex, orderId);
            throw;
        }
    }
}

Generated Message Templates:

ProcessingOrder: OrderId = {OrderId}, CustomerName = {CustomerName}
OrderProcessedSuccessfully: OrderId = {OrderId}
FailedToProcessOrder: OrderId = {OrderId}

Example 2: Scoped Logging

Before (Manual ILogger with Scope):

public async Task ProcessPaymentAsync(Guid paymentId, decimal amount)
{
    using (_logger.BeginScope("Processing payment {PaymentId} for {Amount}", paymentId, amount))
    {
        _logger.LogDebug("Validating payment details");
        
        // Process payment...
        
        _logger.LogInformation("Payment completed successfully");
    }
}

After (Source Generator - Scoped Logging):

// Define scoped logging method (returns IDisposable)
[Logger]
public interface IPaymentTelemetry
{
    [Info]
    IDisposable? ProcessingPayment(Guid paymentId, decimal amount);

    [Debug]
    void ValidatingPaymentDetails();

    [Info]
    void PaymentCompletedSuccessfully();
}

// Usage
public async Task ProcessPaymentAsync(Guid paymentId, decimal amount)
{
    using (_telemetry.ProcessingPayment(paymentId, amount))
    {
        _telemetry.ValidatingPaymentDetails();
        
        // Process payment...
        
        _telemetry.PaymentCompletedSuccessfully();
    }
}

Tip

Return IDisposable? from a logging method to create a scoped log entry. The generated code automatically measures duration and logs at both start and end.

Example 3: LoggerMessage.Define Pattern

Before (High-Performance Logging with LoggerMessage.Define):

public class UserService
{
    private readonly ILogger<UserService> _logger;

    private static readonly Action<ILogger, int, string, Exception?> _userLoggedIn =
        LoggerMessage.Define<int, string>(
            LogLevel.Information,
            new EventId(1001, nameof(UserLoggedIn)),
            "User {UserId} logged in from {IpAddress}");

    private static readonly Action<ILogger, string, Exception?> _invalidLoginAttempt =
        LoggerMessage.Define<string>(
            LogLevel.Warning,
            new EventId(1002, nameof(InvalidLoginAttempt)),
            "Invalid login attempt for username {Username}");

    public UserService(ILogger<UserService> logger)
    {
        _logger = logger;
    }

    public void UserLoggedIn(int userId, string ipAddress)
    {
        _userLoggedIn(_logger, userId, ipAddress, null);
    }

    public void InvalidLoginAttempt(string username)
    {
        _invalidLoginAttempt(_logger, username, null);
    }
}

After (Source Generator):

using Purview.Telemetry;

[Logger]
public interface IUserServiceTelemetry
{
    [Info]
    void UserLoggedIn(int userId, string ipAddress);

    [Warning]
    void InvalidLoginAttempt(string username);
}

// Register in DI
services.AddUserServiceTelemetry();

// Use directly - no manual LoggerMessage.Define needed!
public class UserService
{
    private readonly IUserServiceTelemetry _telemetry;

    public UserService(IUserServiceTelemetry telemetry)
    {
        _telemetry = telemetry;
    }

    public void OnUserLogin(int userId, string ipAddress)
    {
        _telemetry.UserLoggedIn(userId, ipAddress);
    }

    public void OnInvalidLogin(string username)
    {
        _telemetry.InvalidLoginAttempt(username);
    }
}

What Gets Generated:

The source generator creates optimized logging code similar to LoggerMessage.Define, but automatically. You get the same performance without the boilerplate!

// Generated code (simplified for clarity)
private void UserLoggedIn_Logging(int userId, string ipAddress)
{
    if (!_logger.IsEnabled(LogLevel.Information))
        return;

    var state = LoggerMessageHelper.ThreadLocalState;
    state.ReserveTagSpace(3);
    state.TagArray[0] = new("{OriginalFormat}", "UserLoggedIn: UserId = {UserId}, IpAddress = {IpAddress}");
    state.TagArray[1] = new("userId", userId);
    state.TagArray[2] = new("ipAddress", ipAddress);

    _logger.Log(LogLevel.Information, new EventId(...), state, null, static (s, _) => 
        $"UserLoggedIn: UserId = {s.TagArray[1].Value}, IpAddress = {s.TagArray[2].Value}");

    state.Clear();
}

Example 4: Exception Logging

Before (Manual Exception Logging):

public async Task<Data> FetchDataAsync(string url)
{
    try
    {
        return await _httpClient.GetFromJsonAsync<Data>(url);
    }
    catch (HttpRequestException ex)
    {
        _logger.LogError(ex, "HTTP request failed for URL {Url}. Status: {StatusCode}", 
            url, ex.StatusCode);
        throw;
    }
    catch (Exception ex)
    {
        _logger.LogCritical(ex, "Unexpected error fetching data from {Url}", url);
        throw;
    }
}

After (Source Generator):

[Logger]
public interface IDataServiceTelemetry
{
    [Error]
    void HttpRequestFailed(Exception ex, string url, int? statusCode);

    [Critical]
    void UnexpectedErrorFetchingData(Exception ex, string url);
}

public async Task<Data> FetchDataAsync(string url)
{
    try
    {
        return await _httpClient.GetFromJsonAsync<Data>(url);
    }
    catch (HttpRequestException ex)
    {
        _telemetry.HttpRequestFailed(ex, url, (int?)ex.StatusCode);
        throw;
    }
    catch (Exception ex)
    {
        _telemetry.UnexpectedErrorFetchingData(ex, url);
        throw;
    }
}

Note

The Exception parameter is automatically included in the log entry. You don't need to include it in the message template.

Example 5: Complex Structured Data

Before (Manual Structured Logging):

public void RecordOrderDetails(Order order)
{
    _logger.LogInformation(
        "Order details: ID={OrderId}, Items={ItemCount}, Total={Total}, Customer={CustomerId}, Status={Status}",
        order.Id,
        order.Items.Count,
        order.Total,
        order.CustomerId,
        order.Status);
}

After (Source Generator with Enumerable Expansion):

[Logger]
public interface IOrderTelemetry
{
    [Info]
    void RecordingOrderDetails(
        int orderId,
        [ExpandEnumerable(maximumValueCount: 20)] string[] itemNames,
        decimal total,
        int customerId,
        string status);
}

public void RecordOrderDetails(Order order)
{
    _telemetry.RecordingOrderDetails(
        order.Id,
        order.Items.Select(i => i.Name).ToArray(),
        order.Total,
        order.CustomerId,
        order.Status.ToString());
}

Generated Log Output:

RecordingOrderDetails: OrderId = 12345, ItemNames = [ "Widget", "Gadget" ], Total = 99.99, CustomerId = 789, Status = Completed

Tip

Use [ExpandEnumerable] to log array/collection contents. The maximumValueCount parameter limits how many items are logged to prevent unbounded enumeration.

Migration Checklist

When migrating from manual ILogger to the source generator:

  • Create telemetry interface - Define an interface with [Logger] attribute
  • Define methods - Add methods for each log entry with appropriate level attributes ([Info], [Warning], [Error], etc.)
  • Register with DI - Call the generated Add{InterfaceName}() extension method
  • Update constructors - Inject I{YourInterface}Telemetry instead of ILogger<T>
  • Replace log calls - Change _logger.LogXxx() calls to _telemetry.MethodName()
  • Remove LoggerMessage.Define - Delete manual LoggerMessage.Define declarations
  • Test - Verify log messages appear with correct structure

Common Patterns

Pattern 1: Log Level Selection

v3 Style:

_logger.LogInformation("Message");
_logger.LogWarning("Warning");
_logger.LogError(ex, "Error");

Source Generator Style:

[Info]
void Message();

[Warning]
void Warning();

[Error]
void Error(Exception ex);

Available log level attributes: [Trace], [Debug], [Info], [Warning], [Error], [Critical], or [Log(LogLevel.Xxx)]

Pattern 2: Message Template Control

By default, the generator creates a message template from the method name and parameters:

Method: void ProcessingOrder(int orderId, string status)

Generated Template: "ProcessingOrder: OrderId = {OrderId}, Status = {Status}"

You can customize this with the [Log] attribute:

[Log(LogLevel.Information, "Order #{OrderId} is now {Status}")]
void ProcessingOrder(int orderId, string status);

Pattern 3: Event IDs

The generator automatically creates stable event IDs based on method names. You can override:

[Info(EventId = 1001)]
void UserLoggedIn(int userId);

Pattern 4: Conditional Logging

Manual conditional logging:

if (_logger.IsEnabled(LogLevel.Debug))
{
    _logger.LogDebug("Expensive operation result: {Result}", ExpensiveComputation());
}

Source generator (automatically optimized):

[Debug]
void ExpensiveOperationResult(string result);

// Call it - the generated code includes IsEnabled check
_telemetry.ExpensiveOperationResult(ExpensiveComputation());

The generated code automatically includes IsEnabled checks, so you don't need to add them manually.

Next Steps

After migrating to structured logging:

  1. Explore Generation v2 - See Logging Generation v2 for advanced features
  2. Add Activities - Combine logging with distributed tracing in Multi-Targeting
  3. Add Metrics - Track performance alongside logs with Metrics
  4. Configure OpenTelemetry - Export structured logs to observability platforms

Troubleshooting

"Type or namespace 'LoggerAttribute' could not be found"

Make sure you have the correct using statement:

using Purview.Telemetry;  // v4.x

"No logger implementation generated"

Ensure your interface:

  • Has the [Logger] attribute at the interface level
  • Has at least one method with a log level attribute
  • Calls the DI registration method: services.Add{InterfaceName}()

"Parameter names don't match casing in logs"

In v4, parameter names are converted to snake_case by default (OpenTelemetry convention). To revert to v3 behavior:

[assembly: TelemetryGeneration(NamingConvention = NamingConvention.Legacy)]

See Breaking Changes - OpenTelemetry Naming for details.

See Also

Clone this wiki locally