Correlation IDs in F# with Giraffe and Serilog

{{< figure src="/blog/implementing-correlation-ids-fsharp-giraffe-serilog/01.webp" title="São Paulo's Penha subway station. (2022-08-03)" >}}

I spent a stupid amount of time trying to setup an ASP.NET Middleware to handle correlation IDs on requests. I must confess that I just got my first .NET and F#1 job, therefore most of the time spent was just getting used to the whole ecosystem. However during my trial and error I saw a bunch of blog posts showing me how to do this in different manners and a lot discussions about the correct order to implement things.

A correlation ID is a unique ID that is assigned to every transaction. So, when a transaction becomes distributed across multiple services, we can follow that transaction across different services using the logging information. --- Gaurav Kumar Aroraa, Lalit Kale and Kanwar Manish

This was written with the following versions:

  • .NET SDK 6.0.400
  • Giraffe 6.0.0 - dotnet add package Giraffe -v 6.0.0
  • Serilog 2.11.0 - dotnet add package Serilog -v 2.11.0
  • Serilog.AspNetCore - dotnet add package Serilog.AspNetCore -v 6.0.1

Importing the needed modules

Let's get started by importing all the needed packages:

open System
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Hosting
open Microsoft.AspNetCore.Hosting

open Giraffe
open Serilog
open Serilog.Context

Starting the web host

Differently from Saturn, Giraffe doesn't have a computation expression to configure our web host. With that in mind, the code below must do the job.

module Entry =
    open Configuration

    Log.Logger <-
        LoggerConfiguration()
            .Enrich.FromLogContext()
            .WriteTo.Console(
                outputTemplate = "[{Timestamp:HH:mm:ss} {CorrelationId} {Level:u3}] {Message:lj}{NewLine}{Exception}"
            )
            .CreateLogger()

    [<EntryPoint>]
    let main args =
        Host
            .CreateDefaultBuilder(args)
            .ConfigureWebHost(configureWebHost)
            .UseSerilog()
            .Build()
            .Run()

        0

The key parts of the code are:

  • .Enrich.FromLogContext()
  • The outputTemplate containing the CorrelationId property

We will define the configureWebHost in another module called Configuration. This same module contains other helper functions related to the Host configuration.

module Configuration =
    let configureApp (builder: IApplicationBuilder) =
        builder
            .UseMiddleware<Middleware.CorrelationId>()
            .UseGiraffe Endpoint.router

    let configureServices (services: IServiceCollection) = services.AddGiraffe() |> ignore

    let configureWebHost (builder: IWebHostBuilder) =
        builder
            .Configure(configureApp)
            .ConfigureServices(configureServices)
            .UseKestrel()
            .UseUrls([| "http://0.0.0.0:8000" |])
            .UseWebRoot("/")
        |> ignore

Here we can see a Middleware.CorrelationId being implemented as an ASP.NET Middleware.

Implementing the middleware

The mechanism of this middleware is quite simple. One of the possible ways to implement a correlation ID propagation on web APIs is to pass a unique value as request header. In our case, it will be passed around on a header key called X-Correlation-Id.

module Middleware =
    type CorrelationId(next: RequestDelegate) =
        member this.Invoke(context: HttpContext) =
            let headerName = "X-Correlation-Id"
            let logPropertyName = "CorrelationId"

            let success, value =
                context.Request.Headers.TryGetValue headerName

            let correlationId =
                if success
                then value.ToString()
                else Guid.NewGuid().ToString()

            context.Response.Headers.Add(headerName, correlationId)

            using (LogContext.PushProperty(logPropertyName, correlationId)) (fun _ ->
                next.Invoke(context)
            )

The logic is the following:

  1. Check if there's a value on the X-Correlation-Id header key
  2. If there's a value, we turn this into a string. Otherwise, we create a Guid as the correlation id.
  3. Add the header to the response with the extracted correlation id

Testing with an actual request

For a testing purpose, let's create a Hello, World! endpoint with a simple log.

module Endpoint =
    let HelloHandler: HttpHandler =
        fun (next: HttpFunc) (ctx: HttpContext) ->
            Log.Information "Helloing the world!"
            json {| message = "Hello, World!" |} next ctx

    let router = route "/" >=> HelloHandler

Doing a simple request through a web browser should return a basic { "message": "Hello, World!" } json text and show a your console should show the correlation id of our request.

[20:34:49  INF] Application started. Press Ctrl+C to shut down.
[20:34:49  INF] Hosting environment: Production
[20:34:49  INF] Content root path: /home/user/foo/barr
[20:34:49  INF] Request starting HTTP/1.1 GET http://localhost:8000/ - -
[20:34:49 fe7b6dd7-eec4-4792-9fda-de814ef5dd14 INF] Helloing the world!
[20:34:50  INF] Request finished HTTP/1.1 GET http://localhost:8000/ - - - 200 27 application/json;+charset=utf-8 1126.8972ms
  1. It has been my first production encounter with functional programming and I'm loving it! 🤓