Using TaskLocal to Connect Spans

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

From the proposal of task locals, It’s to allow for storing or carrying data for an asynchronous task. The main motivation for such data is to facilitate debugging and tracing.

To explain it in a very simple way, when you pass a property to a method, this property is alive within the scope of the method itself. If that method calls another, those properties won’t be accessible unless it’s also passed on as parameters themselves.

On the other hand, global static properties are accessible from anywhere in your code. Just like grafanaToken that is defined in Common.swift.

Global properties will carry the same value across all of your app. With multi-threading in the past, it was possible to define values local to a thread, but it wasn’t very neat and can make the code complicated. Task locals addresses the same challenge with Swift Concurrency. It works very similar to global properties, but within each task the value can be different. If you set a local value to a task, any tasks starting from it will access the same value.

As for the current challenge, it involves setting up the spans for network operations in TheMetClient.swift without directly passing the span to the method.

To get started, go to Common.swift and add the following:

public enum TracingContext {
  @TaskLocal static var activeSpan: (any Span)?
}

The enum isn’t necessary but it helps keep your code clean. The activeSpan is a static variable that holds an optional Span. The property wrapper @TaskLocal is what makes it different. You can set the value normally:

TracingContext.activeSpan = span

Or use TaskLocal methods to execute a block of code that accesses a unique value:

TracingContext.$activeSpan.withValue(span) {
// do something
}

The withValue() method has two overrides, one for synchronous closures, and another for async closures. Any code within those closures will be able to access the span from the static propery in the enum TracingContext.activeSpan.

Go to TheMetStore.swift, change the implementation of fetchObjects(for:) to the following:

func fetchObjects(for queryTerm: String) async throws {
  let span = OTelSpans.createSpan(scopeName: "TheMet-Tracing", name: "fetchObjects")
  span.setAttribute(key: "SearchKeyword", value: queryTerm)
  try await TracingContext.$activeSpan.withValue(span) { // 1
    if let objectIDs = try await service.getObjectIDs(from: queryTerm) {
      span.setAttribute(key: "ObjectIDsCount", value: objectIDs.objectIDs.count)
      for (index, objectID) in objectIDs.objectIDs.enumerated()
      where index < maxIndex {
        let childSpan = OTelSpans.createSpan(scopeName: "TheMet-Tracing", name: "FetchingObject", parentSpan: TracingContext.activeSpan)
        childSpan.setAttribute(key: "objectID", value: objectID)
        var object: Object?
        try await TracingContext.$activeSpan.withValue(childSpan) { // 2
          object = try await service.getObject(from: objectID)
        }
        if let object {
          await MainActor.run {
            objects.append(object)
          }
          childSpan.status = .ok
        }
        childSpan.end()
      }
    }
  }
  span.setAttribute(key: "ObjectsCount", value: objects.count)
  span.end()
  print("got \(objects.count) objects")
  OTelMetrics.sendGauge(
    metricsGroup: "TheMet-Metrics",
    name: "ObjectsCount",
    value: Double(objects.count))
}

The only difference from before are lines commented 1 and 2. The span now covers service.getObjectIDs(:), and the childSpan covers service.getObject(:) using TaskLocal’s withValue().

This wouldn’t change anything yet but prepares for the next step. Go to TheMetService.swift. In getObjectIDs(from:) create a span right before executing the network request, and end it right after:

let networkSpan = OTelSpans.createSpan(scopeName: "TheMet-Tracing", name: "GetIDs-Network", parentSpan: TracingContext.activeSpan)
let (data, response) = try await session.data(for: request)
networkSpan.end()

Do the same in getObject(from:):

let networkSpan = OTelSpans.createSpan(scopeName: "TheMet-Tracing", name: "GetObject-Network", parentSpan: TracingContext.activeSpan)
let (data, response) = try await session.data(for: objectRequest)
networkSpan.end()

Build and run the app. No need to perform any additional searches.

Open your traces on grafana and open the details of the span.

You’ll see that all the spans are properly connected to their own parent span.

You can see from the code, the methods in TheMetService didn’t know anything about Task locals or needed to do anything special other than regular access to a static property.

Task locals greatly simplified the setup of tracing in your app, it barely required any effort and it didn’t add noticable complexity to your app, and above all, your APIs are untouched.

See forum comments
Download course materials from Github
Previous: Connecting Spans Together Next: Conclusion