[Pitch] Swift Service Context

Hello everyone,
as a companion to the just posted [Pitch] Swift Distributed Tracing we'd like to also pitch the endorsement of the swift-service-context package by the SSWG.


Package Description

ServiceContext is a minimal (zero-dependency) context propagation container, intended to "carry" items for purposes of cross-cutting tools to be built on top of it.

It is modeled after the concepts explained in W3C Baggage and in the spirit of Tracing Plane's "Baggage Context" type, although by itself, it does not define a specific serialization format.

See GitHub - apple/swift-distributed-tracing: Instrumentation library for Swift server applications for actual instrument types and implementations, which can be used to deploy various cross-cutting instruments, all reusing the same service context type. More information can be found in the SSWG meeting notes.

Package Name swift-service-context
Module Name ServiceContextModule
Proposed Maturity Level Incubating
License Apache v2
Dependencies -

Introduction

Service context is a type-safe dictionary that is keyed using types and intended to be used for contextual information propagation across asynchronous code.

It declares a well-known Task Local key for the service context, and patterns how to add keys.

It is a core building block of swift-distributed-tracing which uses it to propagate trace information across swift concurrency tasks, though it can be used independently as well.

Overview

Service context's API surface is relatively small and is all based around the service context type which is a type-safe dictionary.

One can create a service context using the .topLevel factory method which intends to clarify that this should not be used UNLESS intending to start a fresh "top level" context that does not inherit any values from the current context:

var context = ServiceContext.topLevel

In most situations, developers should prefer to "pick up" the context from the current context:

var context: ServiceContext? = ServiceContext.current

which inspects the task-local values for the presence of a service context. It is by design that it is possible to determine if no context was set, or if an empty context is set. When intending to "carry existing, or create a new" context, the following pattern is used:

var context = ServiceContext.current ?? ServiceContext.topLevel

Once obtained, one can set values in by using defined keys, like this:

private enum FirstTestKey: ServiceContextKey {
  typealias Value = Int
}

var context = ServiceContext.topLevel
context[FirstTestKey.self] = 42

Keys declared as types conforming to ServiceContextKey allow for future extension where we might want to configure specific keys using additional behavior, like for example defining the static let nameOverride of a key. It is also important that a Key type may be private to whomever declares it, allowing only such library to set these values.

Service context is used primarily as a task-local value read and modified like this:

func exampleFunction() async -> Int {
  guard let context = ServiceContext.current {
    return 0
  }
  guard let value = context[FirstTestKey.self] {
    return 0
  }
  print("test = \(value)") // test = 42
  return value
}

// ----------------------------------------

var context = ServiceContext.topLevel
context[FirstTestKey.self] = 42

let c = ServiceContext.withValue(context) {
    await exampleFunction()
}
assert(c == 42)

Swift's task local values are used to automatically propagate the context value to any child tasks that may be created from this code, such that context is automatically propagated through to them, e.g. like this:

func testMeMore() {
  guard let context = ServiceContext.current {
    return 0
  }
  guard let value = context[FirstTestKey.self] {
    return 0
  }
  return value
}

func test() async -> Int {
  async let v = testMeMore() // read context from child task
  return await v
}

var context = ServiceContext.topLevel
context[FirstTestKey.self] = 42
ServiceContext.withValue(context) {
    assert(test() == 42)
}

For keys which are expected to be public API, used ty developers, it is recommended to follow this pattern to declare an accessor on the context, rather than exposing the Key type directly:

extension ServiceContext { 
  public var testValue: String? {
    set { 
      self[TestKey.self] = newValue
    }
    get {
      self[TestKey.self]
    }
  }
}

Maturity Justification

We are proposing this package at the "Incubation" level of maturity. We believe this package to be an important building block of the server ecosystem, in the same way swift-log and swift-metrics are adopted across many server and client libraries.

The project has matured for over 3 years, has multiple active maintainers and fulfills adoption requirements in production.

Alternatives considered

Not building a tracing API was considered but we see this as a fundamental building block for the server ecosystem, in the same style as swift-log and swift-metrics.

10 Likes

+1
Can't really have tracing without some way to propagate its baggage. This is a key part of the tracing story although a separate part which can be used in other contexts. It has had a long development cycle and a lot of thought has gone into its design.

3 Likes