How to use OpenTelemetry with F#

In this post I’ll skip ahead to the fun stuff and teach you how you can use OpenTelemetry with F#.

How to install OpenTelemetry

First, make sure you’re using the latest .NET SDK and create a new F# web app:

dotnet new webapp -lang F# -o FSharpOpenTelemetry

Now, install the OpenTelemetry package in the FSharpOpenTelemetry directory:

dotnet add package OpenTelemetry

You can install a preview by providing an explicit version, but the stable version will work fine here.

Full sample

There’s a bit of code to wire up so that you can export to a backend without using a vendor SDK distribution. Here it is, all in Program.fs:

open System
open System.Threading.Tasks

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

open OpenTelemetry.Resources
open OpenTelemetry.Trace

let builder = WebApplication.CreateBuilder(Environment.GetCommandLineArgs())

// substitute your backend config data here, I'm using Honeycomb
let honeycombEndpoint = "https://api.honeycomb.io:443"
let honeycombApiKey = "lol use an environment variable"
let honeycombDataset = "this can also live in appsettings if you prefer"
let serviceName = "FSharpOpenTelemetry"

// Configure an exporter with some important info:
//
// - endpoint stuff you might need (e.g., headers)
// - make sure the service name is set up
// - configure some automatic instrumentation
builder.Services.AddOpenTelemetryTracing(fun builder ->
    builder
        .AddSource(serviceName)
        .AddOtlpExporter(fun otlpOptions ->
            // this config code will depend on the backend you choose.
            // it may require more stuff, less stuff, or just as much stuff!
            otlpOptions.Endpoint <- Uri(honeycombEndpoint)
            otlpOptions.Headers <-
                $"x-honeycomb-team={honeycombApiKey},x-honeycomb-dataset={honeycombDataset}"
        )
        .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(serviceName))
        .AddAspNetCoreInstrumentation(fun opts ->
            opts.RecordException <- true
        )
        .AddHttpClientInstrumentation()
        .AddSqlClientInstrumentation(fun o ->
            o.SetDbStatementForText <- true
            o.RecordException<- true
        )
    |> ignore
) |> ignore

// Start a tracer scoped to the service
let tracer = TracerProvider.Default.GetTracer(serviceName)

let rootHandler () =
    task {
        // Track the work done in the root HTTP handler
        use span = tracer.StartActiveSpan("sleep span")
        span.SetAttribute("duration_ms", 100) |> ignore

        do! Task.Delay(100)

        return "Hello World!"
    }

// Add the handler to the root route using .NET 6 APIs!
let app = builder.Build()
app.MapGet("/", Func<unit, Task<string>>(rootHandler)) |> ignore

app.Run()

There’s a lot more you can fiddle around with:

  • Loading configuration from appsettings.json file
  • Configuring the right API keys
  • Configuring more automatic instrumentation

But in general, this is what you can expect some of your code to look like.

This code will generate a trace with two spans:

  1. A span generated by ASP.NET Core automatic instrumentation for the inbound request
  2. A child span of the automatically-created one that tracks the Task.Delay call

Not exactly a bunch of data, but it should be enough to get you familiar with tracking work in your own codebase.

Using a vendor SDK distribution (Honeycomb!)

I work for Honeycomb, an observability product. We have an SDK distributions for OTel that make config easier and lights up some more automatic instrumentation. Here’s what the same code can look like:

open System
open System.Threading.Tasks

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

open OpenTelemetry.Trace
open Honeycomb.OpenTelemetry

let builder = WebApplication.CreateBuilder(Environment.GetCommandLineArgs())

// Config values stored in appsettings.json, with environment variable overrides
// this includes the service name, which is injected for you
builder.Services.AddHoneycomb(builder.Configuration) |> ignore

let rootHandler (tracer: Tracer) = // this is injected by the honeycomb SDK distro
    task {
        use span = tracer.StartActiveSpan("sleep span")
        span.SetAttribute("duration_ms", 100) |> ignore

        do! Task.Delay(100)

        return "Hello World!"
    }

let app = builder.Build()
app.MapGet("/", Func<Tracer, Task<string>>(rootHandler)) |> ignore

app.Run()

Much nicer! This will more or less generate the same data, but things like redis instrumentation and a few others will also get automatically generated for me. I can also turn off specific pieces of automatic instrumentation if I prefer.

What about System.Diagnostics.DiagnosticsSource?

Oh boy.

In the early days of OpenTelemetry and .NET, a few things happened:

  • The .NET team started baking OpenTelemetry concepts into System.Diagnostics.DiagnosticsSource APIs
  • Microsoft engineers decided that this could be a solid foundation for tracing with OpenTelemetry
  • It was decided that, for the sake familiarity with existing APIs, guidance would be that .NET developers should prefer to use things like ActivitySource and Activity as a noun instead of Tracer and Span

That would make the code sample above look like this:

open System
open System.Threading.Tasks

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

open OpenTelemetry.Trace
open Honeycomb.OpenTelemetry

let builder = WebApplication.CreateBuilder(Environment.GetCommandLineArgs())

// Config values stored in appsettings.json, with environment variable overrides
builder.Services.AddHoneycomb(builder.Configuration) |> ignore

// This is the same thing as a Tracer in OTel
let activitySource = new ActivitySource(serviceName)

let rootHandler () =
    task {
        // Activities and Tags are Spans and Attributes
        use activity = activitySource.StartActivity("sleepy span")
        activity.SetTag("sleepy", "true") |> ignore

        do! Task.Delay(100)

        return "Hello World!"
    }

let app = builder.Build()
// Update the Func<> wrapper's type here
app.MapGet("/", Func<unit, Task<string>>(rootHandler)) |> ignore

app.Run()

I personally don’t like this decision for a few reasons:

  • It effectively violates to OpenTelemetry spec, since nouns and verbs are using an older .NET API that came before OpenTelemetry (even if it’s all compliant under the hood)
  • There is no shared terminology with other languages, making it harder to collaborate with others on a team
  • It feels like the decision was to optimize for legacy codebases and users

Additionally, System.Diagnostics.DiagnosticsSource also has the concept of Baggage (a kind of metadata you can propagate across a trace), but the System.Diagnostics Baggage API today is not OpenTelemetry spec compliant. And so you must use the OpenTelemetry Baggage API instead, otherwise things won’t work.

Will this all get resolved? Time will tell, but I would personally recommend that you use the OpenTelemetry nouns and verbs over System.Diagnostics.DiagnosticsSource stuff if you can:

  • This ensures you can look at anything else online related to OpenTelemetry and have a shared vocabulary
  • Working in a polyglot codebase is easier since there’s no need to mentally translate nouns and verbs
  • It feels good to be spec-compliant

If you stick with the official guidance, which is to use System.Diagnostics.DiagnosticsSource, then we can’t be friends. I’m kidding; we can be friends.

Misc Q & A

Is OpenTelemetry stable?

Yes! Tracing with OTel is 1.0 and most SDKs are also 1.0 now with tracing. Traces are the most important data type, so go forth and instrument with it now!

Metrics are stable in the spec, but SDK support is still experimental-to-beta depending on the language.

Logs are still experimental, but that’s fine for now I guess.

.NET in particular is very good with OpenTelemetry. The .NET team and some other folks working at Microsoft have taken great care to bake in OTel concepts to .NET itself, and the packages are stable, well-tested, etc.

You can rely on OpenTelemetry for your production services.

What about using something other than Honeycomb?

Honeycomb isn’t the only vendor that supports OpenTelemetry data.

Some vendors still don’t support native OTLP ingest, so you might need to run the data through a proxy or exporter component that they provide to make it work.

And there are OSS exporters like for Zipkin that work out of the box if you want to self-host your telemetry data.

But you should really not be in the business of building out your own observability stack. It’s really hard tech.

Scoping with use

One gotcha with OpenTelemetry and span lifecycles is that when they’re disposed of (i.e., go out of scope), they end. THis is super convenient if you have one span tracking work for one function or method.

But if you create child spans in the same scope as a parent span, you might need to explicitly end them, otherwise they’ll continue to track work until they go out of scope, which you might not have intended!

let doSomeWork (tracer: Trace) =
    task {
        // track the work here
        use parentSpan = tracer.StartActiveSpan("parent")

        // do some work
        do! Task.Delay(100)

        // track some more work with a child span
        use childSpan = tracer.StartActiveSpan("child")
        doMoreWork(childSpan)

        // If you don't call this, then childSpan will still track work in
        // this function! That might be a bug, so end it here, or create the
        // child span in the 'doMoreWork' function.
        childSpan.End()

        // do more work for the parent to track
        do! Task.Delay

        // the parent span goes out of scope and will then end now
    }

Why the |> ignore? Is there not an F#-friendly version to use instead?

I spent some time playing around with some little F# helpers to see if it was possible to make the API a little nicer to use from F#. Unfortunately, I couldn’t come up with anything I liked:

  • Adding nicer overloads: Most routines return a type for fluent-style usage (even though that’s rarely done), and .NET can’t overload based on return type, so this isn’t possible.
  • Adding FSharp-branded overloads: An alternative is to make overloads for everything (e.g., SetAttributeFSharp)that do nothing except ignore the return value, but this quickly felt silly, and I think it would raise a lot more eyebrows than make F# developers happier.
  • Module-bound function helpers: Something like Span.setAttribute key value span function call, with maybe some way to chain calls with |> pipelines ended up being kind of silly too. Chaining calls is actually quite rare, so it ends up being a wash in terms of number of characters typed
  • Wrapping core types to be F#-friendly: This is certainly possible (e.g., FSharpTelemetrySpan and perhaps some FSharp-branded new overloads for various things) – but it sucks. OpenTelemetry is designed to be as low-footprint as possible, and wrapping structs to re-create an API surface area all in the name of getting rid of some |> ignore calls isn’t that great.
  • Computation expressions: I also experimented with a computation expression with some custom operations so that you could do something like this:
use span = tracer.StartActiveSpan("parent")
span  {
    set_attribute "key" "value"
    add_event "some-event" DateTimeOffset.Now
} |> ignore

It was kinda neat, but also ended up being a wash in terms of the amount of code you need to type.

One thing that you could consider adding to a prelude somewhere in your codebase is this little type extension:

type Link with
    static member New(spanContext: SpanContext) =
        Link(&spanContext)
    static member New(spanContext: SpanContext, spanAttributes: SpanAttributes) =
        Link(&spanContext, spanAttributes)

This just makes some annoying inref<> stuff easier to deal with. You might find little things like this to do too. Perhaps someone more creative than me will figure out a brilliant OpenTelemetry.FSharp helper package.

Unfortunately, the .NET OpenTelemetry API is a little annoying to use from F#, and there aren’t any awesome solutions to make that any better. But it’s not the end of the world by any means.