C# IActionFilter

IActionFilter is a neat interface that may be implemented and then added to a .NET Core WebApi project to execute logic when a WebApi action method executes.

A barebones implementation may look like this

public class CustomEventAggregatorFilter : IActionFilter
{
    public void OnActionExecuted(ActionExecutedContext filterContext)
    {
    }

    public void OnActionExecuting(ActionExecutingContext filterContext)
    {
    }
}

On its own, this doesn't do anything. But let's add it into the pipeline anyway.

It goes into Startup.cs

        public void ConfigureServices(IServiceCollection services)
        {
            services
                .AddMvc(x =>
                {
                    x.Filters.AddService<CustomEventAggregatorFilter>();
                })
                .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

            services.AddScoped<CustomEventAggregatorFilter>();
        }

The name CustomEventAggregatorFilter will make sense later.

Adding AppMetrics

An arbitrary action I've decided that my CustomEventAggregatorFilter will perform, is to increment an event counter.

This event counter will come from App Metrics "App Metrics is an open-source and cross-platform .NET library used to record metrics within an application."

To set up the App Metrics library to report via a Prometheus endpoint and to report some default statistics, use the below setup from Program.cs

        public static IWebHostBuilder CreateWebHostBuilder(string[] args)
        {   
            var metrics = AppMetrics.CreateDefaultBuilder()
                .OutputMetrics.AsPrometheusPlainText()
                .OutputMetrics.AsPrometheusProtobuf()
                .Build();

            return WebHost.CreateDefaultBuilder(args)
                .ConfigureMetrics(metrics)
                .UseMetrics(
                    options =>
                    {
                        options.EndpointOptions = endpointsOptions =>
                        {
                            endpointsOptions.MetricsTextEndpointOutputFormatter = 
                                metrics.OutputMetricsFormatters.GetType<MetricsPrometheusTextOutputFormatter>();
                            endpointsOptions.MetricsEndpointOutputFormatter = 
                                metrics.OutputMetricsFormatters.GetType<MetricsPrometheusProtobufOutputFormatter>();
                        };
                    })
                .UseMetricsWebTracking()
                .UseStartup<Startup>();
        }

If I navigate to the default address "https://localhost:5001/metrics-text" I can see some empty statistics.

Empty

Custom events

Since there's some default stats showing, I can add in some custom events by defining an attribute to hold some metadata. The only thing I want it to hold is an EventName.

    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true, AllowMultiple = true)]
    public class ReportCustomEventAttribute: Attribute
    {
        public string EventName { get; }

        public ReportCustomEventAttribute(string EventName)
        {
            this.EventName = EventName;
        }
    }

This attribute class allows me to decorate actions on a controller. The default ValueController created is nice enough.

    [Route("api/[controller]")]
    [ApiController]
    public class ValuesController : ControllerBase
    {
        // GET api/values
        [HttpGet]
        [ReportCustomEvent("GetAllValues")]
        public ActionResult<IEnumerable<string>> Get()
        {
            return new string[] { "value1", "value2" };
        }

        // GET api/values/5
        [HttpGet("{id}")]
        [ReportCustomEvent("GetValueById")]
        public async Task<ActionResult<string>> Get(int id)
        {
            await Task.Delay(1000);

            return "value";
        }
    }

By itself, this attribute decorating these methods doesn't do anything. But it's something that can be read by the IActionFilter as filled out below.

    public class CustomEventAggregatorFilter : IActionFilter
    {
        private readonly IMetricsRoot metrics;
        private CounterOptions eventCounter;

        public CustomEventAggregatorFilter(IMetricsRoot metricsRoot)
        {
            metrics = metricsRoot;
            eventCounter = new CounterOptions
            {
                Name = "custom_event",
                MeasurementUnit = Unit.Events
            };
        }

        public void OnActionExecuted(ActionExecutedContext filterContext)
        {
        }

        public void OnActionExecuting(ActionExecutingContext filterContext)
        {
            if (filterContext.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor)
            {
                var actionAttributes = (ReportCustomEventAttribute[])controllerActionDescriptor.MethodInfo.GetCustomAttributes(inherit: true, attributeType: typeof(ReportCustomEventAttribute));
                foreach (var attribute in actionAttributes)
                {
                    this.metrics.Measure.Counter.Increment(eventCounter, attribute.EventName);
                }
            }
        }
    }

With the above OnActionExecuting, every call to the decorated actions above will use the EventName to log a custom event that Prometheus may pick up.

Custom Event

Summary

All sorts of custom logic may be added via IActionFilters and Attributes with metadata.

For reference, the below is my csproj file.

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp2.2</TargetFramework>
    <AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="App.Metrics" Version="3.0.0" />
    <PackageReference Include="App.Metrics.AspNetCore" Version="3.0.0" />
    <PackageReference Include="App.Metrics.AspNetCore.Mvc" Version="3.0.0" />
    <PackageReference Include="App.Metrics.AspNetCore.Tracking" Version="3.0.0" />
    <PackageReference Include="App.Metrics.Formatters.Prometheus" Version="3.0.0" />
    <PackageReference Include="Microsoft.AspNetCore.App" />
    <PackageReference Include="Microsoft.AspNetCore.Razor.Design" Version="2.2.0" PrivateAssets="All" />
  </ItemGroup>

</Project>