Filter Serilog Events By Multiple EventIds In C#

by Henrik Larsen 49 views

Hey everyone! 👋 Today, we're diving into a common scenario when working with Serilog in C#: filtering log events based on multiple EventId values. You might have specific events you want to capture or exclude, and knowing how to filter by EventId is super useful.

The Challenge: Filtering by EventId

So, you're using Serilog, which is awesome! You've got your logging all set up, and you're generating events with EventId properties. But now, you need to filter these events. Maybe you want to send certain events to a specific sink (like a database or a file), or perhaps you want to suppress noisy events. The challenge is, how do you efficiently filter when you need to match either of two (or more) EventId values?

The Old Way (and Why It Doesn't Work)

You might stumble upon older solutions that suggest using Filter.ByIncludingOnly("EventId.Id = 2003"). However, this approach is outdated and won't work with the latest Serilog versions. Serilog's filtering mechanism has evolved, and we need to use a more modern approach.

Modern Solutions for Filtering by Multiple EventIds

Okay, let's get into the good stuff! There are a couple of excellent ways to filter Serilog events by multiple EventId values. We'll explore two primary methods:

1. Using Filter.ByIncludingOnly() with a Lambda Expression

This is the recommended and most flexible approach. We'll use a lambda expression within Filter.ByIncludingOnly() to define our filtering logic. This allows us to check if the EventId.Id matches any of our desired values.

Let's break down how to do it. Imagine you want to filter events with EventId 101 or 202. Here's the C# code:

using Serilog;
using Serilog.Events;

public class Example
{
    public static void Main(string[] args)
    {
        Log.Logger = new LoggerConfiguration()
            .MinimumLevel.Information()
            .Filter.ByIncludingOnly(e => e.Properties.ContainsKey("EventId") &&
                                         (e.Properties["EventId"] as StructureValue)?.Properties
                                         .Any(p => p.Name == "Id" && (p.Value as ScalarValue)?.Value is int id && (id == 101 || id == 202)) == true)
            .WriteTo.Console()
            .CreateLogger();

        Log.Information("This is an informational message.");
        Log.Information(new EventId(101, "ImportantEvent"), "This is an important event with Id 101");
        Log.Information(new EventId(202, "AnotherImportantEvent"), "This is another important event with Id 202");
        Log.Information(new EventId(303, "UnrelatedEvent"), "This event should not be included.");

        Log.CloseAndFlush();
    }
}

Dissecting the Code

Let's walk through this code snippet step by step:

  1. Log.Logger = new LoggerConfiguration(): We start by configuring our Serilog logger.
  2. .MinimumLevel.Information(): We set the minimum log level to Information, meaning we'll capture Information, Warning, Error, and Fatal events.
  3. .Filter.ByIncludingOnly(e => ...): This is where the magic happens! We use Filter.ByIncludingOnly() to specify a filter based on a lambda expression (e => ...). The e represents a LogEvent.
  4. e.Properties.ContainsKey("EventId"): First, we check if the log event has an EventId property. This is crucial because not all log events will have an EventId.
  5. (e.Properties["EventId"] as StructureValue)?.Properties ...: Here, we're digging into the structure of the EventId property. EventId is typically stored as a StructureValue in Serilog. We need to access its properties.
  6. .Any(p => p.Name == "Id" && (p.Value as ScalarValue)?.Value is int id && (id == 101 || id == 202)): This is the core of our filtering logic. We use the Any() method to check if any of the properties within the EventId structure meet our criteria. Let's break this down further:
    • p.Name == "Id": We're looking for the property named "Id" within the EventId structure (this is where the numeric ID is stored).
    • (p.Value as ScalarValue)?.Value is int id: We cast the value of the "Id" property to a ScalarValue and then extract its underlying value. We also check if it's an integer and store it in the id variable.
    • (id == 101 || id == 202): Finally, we check if the id matches either 101 or 202. If it does, the Any() method returns true, and the log event passes the filter.
  7. == true: This explicit comparison to true ensures that we're only including events where the Any() method returned true.
  8. .WriteTo.Console(): We're writing the filtered events to the console. You can replace this with any other Serilog sink (e.g., file, database, etc.).
  9. .CreateLogger(): We create the logger instance.
  10. Log.Information(...): We log various messages, including those with EventId 101, 202, and 303, to demonstrate the filtering.
  11. Log.CloseAndFlush(): We ensure all log events are written before the application exits.

Why This Approach Rocks

  • Flexibility: Lambda expressions give you incredible flexibility. You can create complex filtering logic, including checking multiple properties and using various conditions.
  • Readability: While the lambda expression might look a bit daunting at first, it's quite readable once you understand the structure of Serilog's LogEvent and EventId representation.
  • Maintainability: This approach is easy to maintain and modify. If you need to add or remove EventId values, you can simply update the (id == 101 || id == 202) part of the expression.

2. Creating a Custom Filter Class

For more complex scenarios or when you want to reuse your filtering logic, creating a custom filter class is an excellent option. This approach involves implementing the ILogEventFilter interface.

Here's how you can create a custom filter class for matching multiple EventId values:

using Serilog.Core;
using Serilog.Events;
using System.Collections.Generic;
using System.Linq;

public class EventIdFilter : ILogEventFilter
{
    private readonly HashSet<int> _eventIds;

    public EventIdFilter(IEnumerable<int> eventIds)
    {
        _eventIds = new HashSet<int>(eventIds);
    }

    public bool IsEnabled(LogEvent logEvent)
    {
        if (logEvent.Properties.TryGetValue("EventId", out var eventIdValue) &&
            eventIdValue is StructureValue structureValue)
        {
            var idProperty = structureValue.Properties.FirstOrDefault(p => p.Name == "Id");
            if (idProperty?.Value is ScalarValue scalarValue && scalarValue.Value is int id)
            {
                return _eventIds.Contains(id);
            }
        }
        return false;
    }
}

Diving into the Custom Filter

  1. public class EventIdFilter : ILogEventFilter: We define a class named EventIdFilter that implements the ILogEventFilter interface. This interface has a single method: IsEnabled().
  2. private readonly HashSet<int> _eventIds;: We use a HashSet<int> to store the EventId values we want to match. A HashSet provides efficient lookups (O(1) complexity).
  3. public EventIdFilter(IEnumerable<int> eventIds): The constructor takes an IEnumerable<int> of EventId values and initializes the _eventIds set.
  4. public bool IsEnabled(LogEvent logEvent): This is the heart of our filter. It's called for each log event, and it returns true if the event should be included (i.e., it matches our filter criteria) and false otherwise.
  5. if (logEvent.Properties.TryGetValue("EventId", out var eventIdValue) && eventIdValue is StructureValue structureValue): Similar to the lambda expression approach, we first check if the log event has an EventId property and if its value is a StructureValue.
  6. var idProperty = structureValue.Properties.FirstOrDefault(p => p.Name == "Id");: We try to find the property named "Id" within the EventId structure.
  7. if (idProperty?.Value is ScalarValue scalarValue && scalarValue.Value is int id): If we found the "Id" property, we check if its value is a ScalarValue and if its underlying value is an integer.
  8. return _eventIds.Contains(id);: Finally, we check if the extracted id is present in our _eventIds set. If it is, we return true (the event should be included); otherwise, we return false.
  9. return false;: If any of the checks fail (e.g., the log event doesn't have an EventId, or the "Id" property is not an integer), we return false.

Using the Custom Filter

To use this custom filter, you'll need to incorporate it into your Serilog configuration:

using Serilog;
using Serilog.Events;
using System.Collections.Generic;

public class Example
{
    public static void Main(string[] args)
    {
        Log.Logger = new LoggerConfiguration()
            .MinimumLevel.Information()
            .Filter.ByIncludingOnly(new EventIdFilter(new List<int> { 101, 202 }))
            .WriteTo.Console()
            .CreateLogger();

        Log.Information("This is an informational message.");
        Log.Information(new EventId(101, "ImportantEvent"), "This is an important event with Id 101");
        Log.Information(new EventId(202, "AnotherImportantEvent"), "This is another important event with Id 202");
        Log.Information(new EventId(303, "UnrelatedEvent"), "This event should not be included.");

        Log.CloseAndFlush();
    }
}

We simply create an instance of our EventIdFilter, passing in the list of EventId values we want to match, and then use it within Filter.ByIncludingOnly().

Benefits of a Custom Filter

  • Reusability: You can reuse this filter across multiple logging configurations.
  • Testability: Custom filters are easier to unit test because you can directly test the IsEnabled() method.
  • Complexity Handling: For very complex filtering logic, a custom filter class can make your configuration cleaner and more organized.

Choosing the Right Approach

So, which approach should you use? Here's a quick guide:

  • Lambda Expression: Use this for simple filtering logic that's specific to a single logging configuration. It's quick and easy for straightforward scenarios.
  • Custom Filter Class: Use this for more complex filtering logic, when you need to reuse the filter across multiple configurations, or when you want to improve testability.

Key Takeaways

  • Filtering by EventId in Serilog is essential for managing your logs effectively.
  • The outdated Filter.ByIncludingOnly("EventId.Id = 2003") approach no longer works.
  • Lambda expressions within Filter.ByIncludingOnly() provide a flexible and readable way to filter by multiple EventId values.
  • Custom filter classes offer reusability, testability, and better organization for complex filtering scenarios.

Wrapping Up

Filtering Serilog events by multiple EventId values is a powerful technique for controlling which events are processed by your sinks. Whether you choose the lambda expression approach or create a custom filter class, you now have the tools to efficiently manage your logs. Happy logging, guys! 🎉