Published on

Building Type-Safe Metrics API in Swift: Part II

Authors

This is part II of a two-part series on designing type-safe Swift APIs. If you haven't read Part I yet, I highly recommend starting there, as we covered protocol extensions for default values, enums with associated values, and ExpressibleByStringLiteral for cleaner syntax.

Hero image showing the final Metrics API code

In Part I, we built the foundation of our Metrics API: type-safe methods with optional parameters and flexible unit types. Now it's time to add our last parameter to the public methods: Attributes.

Attributes are a list of key-value pairs with a String as a key and a value of one of our supported data types. In this post, we'll explore:

  • Why using Any for attribute values leads to unusable data
  • How to build a protocol-based "union type" that only accepts valid values
  • Navigating Swift compiler limitations with array conformance
  • Future-proofing your enums with @unknown default

Let's dive in.

Adding Context With Attributes

At the time of writing this blog, these are the value types supported by Sentry's data processing:

  • string
  • boolean
  • integer (64-bit signed integer)
  • double (64-bit floating point number)
  • array (single type, but mixed types in the future)

Attributes are not a new addition to the SDK, as they're already used by the Logs feature released with v8.54.0.

During the initial implementation of logging, we decided to adopt a generic type Any for the value of the attributes, allowing us to include all of the supported types, while also being compatible with Objective-C.

// Source: https://github.com/getsentry/sentry-cocoa/blob/09a80f2770eaf5d8e6fc34a33a4e8e6939393d0a/Sources/Swift/Tools/SentryLogger.swift
@objc(info:attributes:)
public func info(_ body: String, attributes: [String: Any]) {
    // Convert provided attributes to SentryLog.Attribute format
    var logAttributes = attributes.mapValues { SentryLog.Attribute(value: $0) }

    // Create and capture a full log entry
    let log = SentryLog(
        timestamp: dateProvider.date(),
        traceId: SentryId.empty,
        level: level,
        body: SentryLogMessage(stringLiteral: body),
        attributes: logAttributes
    )
    delegate.capture(log: log)
}

The type SentryLog.Attribute is actually a typealias for the SentryAttribute which is a class type holding a String identifier type and a type-erased property value.

This works as expected, but requires a lot of manual type-erasing and type-casting, so when it came to designing the new Swift-only Metrics API, we started again from scratch.

During the first review discussions we considered the idea of using an array of SentryAttribute as the parameter, which got scratched immediately because we would not benefit from compile-time checking for duplicate key literal values, which we get when using the dictionary:

// Definition:
func count(key: String, value: UInt, attributes: [SentryAttribute])

// Usage with array of attributes
SentrySDK.metrics.count(
    key: "network.request.count",
    value: 1,
    attributes: [
        SentryAttribute(key: "endpoint", value: "/api/users"),
        SentryAttribute(key: "endpoint", value: "/api/users/123"), // ❌ This would compile
    ]
)

// Usage with dictionary of attribute values
SentrySDK.metrics.count(
    key: "network.request.count",
    value: 1,
    attributes: [
        "endpoint": "/api/users",
        "endpoint": "/api/users/123", // ✅ Will not compile
    ]
)

This was enough reason to decide that we still want to have a dictionary of String keys with associated values supporting multiple types.

But do we really want to have type-erased value types? Can't we use Swift to define a list of types possible for the value of the attributes?

Understanding The Problem Of Any

As a first step to find a solution, we need to understand our problem.

One major drawback of using Any as the value of our attributes is missing compile-time hints if the passed-in value is not one of our supported attribute value types.

To visualize this, take a look at the following example from the Logs API, where we set a String, an Int, a Double and a custom class type instance as attributes:

class User {
    let id = "user_123"
    let name = "Jane"
}
let currentUser = User()

SentrySDK.logger.info("Purchase completed", attributes: [
    "product_name": "Premium Plan",
    "price": 99,
    "discount_percent": 15.5,
    "user": currentUser  // Oops - passing the whole object
])

This is valid code which will compile, because using type-erased Any for the value will allow passing anything. As a fallback for unknown types such as User, we are performing an internal conversion to String, resulting in the following serialized data sent to Sentry:

{
  "severity_number": 9,
  "body": "Purchase completed",
  "attributes": {
    "product_name": {
      "value": "Premium Plan",
      "type": "string"
    },
    "price": {
      "value": 99,
      "type": "integer"
    },
    "discount_percent": {
      "value": 15.5,
      "type": "double"
    },
    "user": {
      "value": "MyApp.MyApp.(unknown context at $103d12130).(unknown context at $103d1213c).User",
      "type": "string"
    }
  }
}

I believe it's obvious for all readers that MyApp.MyApp.(unknown context at $103d12130).(unknown context at $103d1213c).User is pretty much useless as an attribute value. Even worse, the $103d12130 and $103d1213c are actually memory addresses, so they will be different with every attribute sent, making it non-deterministic and unusable for querying.

One variant to improve this is adopting the protocol CustomStringConvertible, requiring us to implement the description getter method (similar to toString() in other programming languages):

class User: CustomStringConvertible {
    let id = "user_123"
    let name = "Jane"

    var description: String {
        return "<User: id=\(id), name=\(name)>"
    }
}

This example then serializes to a more useful payload:

{
  "user": {
    "value": "<User: id=user_123, name=Jane>",
    "type": "string"
  }
}

This looks already way better, as the memory addresses are now gone, and we can actually see the values themselves. But this already raised the next concerns:

  • Does every type now need to adopt CustomStringConvertible just in case I accidentally use it as a value?

Yes, in case you keep using class types as attribute values, they need to adopt the protocol; otherwise, we get the memory addresses back. And yes, this is inconvenient.

  • Do we really want multiple values in a single attribute?

No, you most likely do not want this, as you want attribute values to be simple and deterministic in meaning, so you can easily write queries in Sentry and explore your data. Having them in the same attribute brings in complexity for querying, both for you and for us at Sentry, so generally speaking, it's easier to split them up.

  • So if I shouldn't do this, why can't the compiler tell me that I am using a type which will require a fallback, and maybe even produce garbage value data?

That's the exact question we asked ourselves too, resulting in us adopting more Swift language features as you can see in the next sections of this blog post.

One Type To Rule Them All

As a first step we use the same approaches as described in our previous post for SentryUnit by introducing an enum with associated values: SentryAttributeContent.

(P.S. there were many rounds of renamings, from "value" to "content" etc., but we decided on this one simply because naming is hard).

enum SentryAttributeContent {
    case string(String)
    case boolean(Bool)
    case integer(Int)
    case double(Double)
    case stringArray([String])
    case booleanArray([Bool])
    case integerArray([Int])
    case doubleArray([Double])
}

protocol SentryMetricsApiProtocol {
    func count(key: String, value: UInt, attributes: [String: SentryAttributeContent])
}

SentrySDK.metrics.count(key: "checkout.completed", value: 1, attributes: [
    "payment_method": .string("apple_pay"),
    "cart_items": .integer(3),
    "total_amount": .double(99.99)
])

This is already way better than using Any, because now we can only pass in attribute values which are defined as known associated value types of our enum.

So, are we ready to ship? 🚀 Not quite yet, because just a bit more engineering and we realize that while our protocol allows Double values, it does not allow Float values, leaving us with an ugly conversion like this:

let latency: Float = 123.456
SentrySDK.metrics.distribution(key: "network.latency", value: 123, attributes: [
    "body_size": .double(Double(latency))
])

On top of that, we now have, once again, like in the SentryUnit, growing boilerplate code, requiring us to convert our variables and literals to enum values every single time.

So what's the Swift-y way to handle this? Exactly! One type protocol to rule them all.

protocol SentryAttributeValue {
    var asSentryAttributeContent: SentryAttributeContent { get }
}

protocol SentryMetricsApiProtocol {
    func count(key: String, value: UInt, attributes: [String: any SentryAttributeValue])
}

With this new protocol, we change the method signature of our public API once again and now it's using the any keyword instead of a concrete type for the attribute value. Due to this change it now accepts all types which adopted the protocol SentryAttributeValue, therefore declaring that they have a getter method or property to represent themselves as SentryAttributeContent enum value.

Now every type can define itself as being representable as one of our supported types, especially types available in the Swift standard library, but also your custom types like User:

extension String: SentryAttributeValue {
    var asSentryAttributeContent: SentryAttributeContent {
        return .string(self)
    }
}

extension Bool: SentryAttributeValue {
    var asSentryAttributeContent: SentryAttributeContent {
        return .boolean(self)
    }
}

extension Int: SentryAttributeValue {
    var asSentryAttributeContent: SentryAttributeContent {
        return .integer(self)
    }
}

extension Double: SentryAttributeValue {
    var asSentryAttributeContent: SentryAttributeContent {
        return .double(self)
    }
}

extension Float: SentryAttributeValue {
    var asSentryAttributeContent: SentryAttributeContent {
        return .double(Double(self)) // ✅ Float-to-Double conversion is hidden away
    }
}

class User: SentryAttributeValue {
    let id = "user_123"

    var asSentryAttributeContent: SentryAttributeContent {
        return .string(id) // ✅ Custom types can represent themselves as supported content types
    }
}

These extensions are part of the SDK and available by default, therefore everyone can now use the Metrics API using variables and literals in attributes:

let paymentMethod = "apple_pay" // ✅ Variables work as expected
SentrySDK.metrics.count(
    key: "checkout.completed",
    value: 1,
    attributes: [
        "payment_method": paymentMethod,
        "cart_items": 3,           // ✅ Integer literals just work
        "is_first_purchase": true  // ✅ Booleans too
    ]
)

Encountering Compiler Limitations

You might have noticed that I did not mention the support of Array much yet. That's due to array handling being quite complex, so I want to dedicate this section to it.

As we have established already, we need to extend Array so it also adopts and implements the method of SentryAttributeValue, but for the best user experience, we want to extend it only if the array contains elements which are one of our supported types.

The initial approach was using extension with a generic where-clause like extension <TYPE> where <CONDITION> to add logic to a TYPE only if a CONDITION on the typing is fulfilled.

extension Array: SentryAttributeValue where Element == Int {
    var asSentryAttributeContent: SentryAttributeContent {
        .integerArray(self)
    }
}

While this works if we write the extension only for a single type, we started to hit compiler errors with multiple type extensions:

Compiler error when multiple conformances to same protocol

Bummer! We can't have multiple conformances of the same protocol scoped to specific element types. Luckily we already introduced SentryAttributeValue as our "union" of supported types which can be applied here:

extension Array: SentryAttributeValue where Element == SentryAttributeValue {
    var asSentryAttributeContent: SentryAttributeContent {
        if Element.self == Bool.self, let values = self as? [Bool] {
            return .booleanArray(values)
        }
        // ... and other cases

        // Fallback to converting to strings
        return .stringArray(self.map { element in
            String(describing: element)
        })
    }
}

For the sake of readability of this blog post I am not going to embed the entire casting logic here, so if you want to see it in detail, all of our source code is open source for you to check out.

This worked well (for a while), as we were now able to pass in arrays of String , arrays of Bool, etc. for all the types which adopted SentryAttributeValue:

SentrySDK.metrics.count(
    key: "order.placed",
    attributes: [
        "customer_id": "cust_456",           // ✅ String works
        "product_ids": ["sku_1", "sku_2"],   // ✅ Array of String works
        "quantities": [2, 1, 3]              // ✅ Array of Integer works too
    ]
)

But there was already another pattern becoming visible: all of the arrays are homogeneous to a single type, therefore they were not actually arrays of SentryAttributeValue, but arrays of types adopting SentryAttributeValue.

It's a thin line in definition, which surfaced a challenge when mixing multiple types adopting SentryAttributeValue into a single array, which we could not prohibit from happening. We hoped that the compiler would somehow be smart enough to understand that now it's an array of SentryAttributeValue, but instead it fell back to an array of Any.

struct ProductID: SentryAttributeValue {
    var asSentryAttributeContent: SentryAttributeContent {
        return .string("product_1")
    }
}

struct CategoryID: SentryAttributeValue {
    var asSentryAttributeContent: SentryAttributeContent {
        return .string("electronics")
    }
}

SentrySDK.metrics.count(
    key: "page.viewed",
    attributes: [
        // Mixed array of types adopting SentryAttributeValue
        // Both return string content, so this could be a string[]
        // ❌ Compiler sees [Any], not [SentryAttributeValue], and fails
        "related_items": [ProductID(), CategoryID()]
    ]
)

As Any is a type which cannot be extended nor does it have a clear representation as an attribute value, we had to remove the condition from the Array extension and add additional casting:

extension Array: SentryAttributeValue { // ✅ removed the where-clause
    var asSentryAttributeContent: SentryAttributeContent {
        if Element.self == Bool.self, let values = self as? [Bool] {
            return .booleanArray(values)
        }
        // ... and other cases
        if let values = self as? [SentryAttributeValue] {
            return castArrayToAttributeContent(values: values)
        }
        // Fallback to converting to strings
        return .stringArray(self.map { element in
            String(describing: element)
        })
    }
}

This was the final solution which now casts from arrays of Any to our known types, including handling of other types adopting the protocol, and a fallback to arrays of String for everything else.

Granular Control

As it is common in our Sentry SDKs, we want to allow our users to be able to manually filter and manipulate collected metric items for data enrichment, data scrubbing, and other use cases, before they are sent to Sentry.

This was also decided for the Metrics feature, so we introduced the option beforeSendMetric, which is a "[..] function that takes a metric object and returns a metric object [..] called before sending the metric to Sentry".

To embrace the Swift-iness of our implementation we also reconsidered the need for using class-based reference type instances for the metrics objects. Instead, they should be handled as immutable data inside of the SDK and only be transformed/mapped if needed. We decided to use struct data types with SentryMetric as our parameter type and SentryMetric? as a nullable return type.

While this removes compatibility with Objective-C (as struct is Swift-only), the metric is passed as an immutable copy to the beforeSendMetric closure and cannot be modified directly, unless it's copied to a local variable first. We also considered passing it in as an inout parameter to allow modification via a reference, but decided against it because it would require us to change the input parameter to be nullable too (which bad practice as it is never the case).

For the type of the attributes property of the metric, we decided to expose the dictionary values not using SentryAttributeValue as in the capturing methods, but instead directly the enum SentryAttributeContent. This allows you to identify and modify the typed metrics using switch for multi-case or if case for single-case handling.

Bringing it all together the beforeSendMetric can now be used like this:

// Experimental for now, will be a top-level option in the future
class SentryExperimentalOptions {
    var beforeSendMetric: ((Sentry.SentryMetric) -> Sentry.SentryMetric?)?
}

options.experimental.beforeSendMetric = { metric in
    // Create a mutable copy (SentryMetric is a struct)
    var metric = metric

    // Drop metrics with specific attribute values set
    if case .boolean(let dropMe) = metric.attributes["dropMe"], dropMe {
        return nil
    }

    // Modify metric attributes using literals converted to our enum types
    metric.attributes["processed"] = true
    metric.attributes["processed_at"] = "2024-01-01"

    return metric
}

Forwards-Compatibility

During one of our review discussions we encountered an interesting edge case with regards to forward compatibility:

When using an enum in a switch case matching, it is necessary to handle either all cases, or to define a default case to match the unhandled ones:

// Example type with subset of all supported types
enum Value {
    case boolean(Bool)
    case integer(Int)
    case string(String)
}

// Default case for unhandled ones
switch value {
case .boolean(let val):
    // val is true or false
default: // ⚠️ required
    // do nothing
}

// Handle all cases
let value: Value = ...
switch value {
case .boolean(let val):
    // val is true or false
case .integer(let val):
    // val is an integer
case .string(let val):
    // val is a String

// default: ✅ not necessary
}

The important aspect here is that the enum is defined in our SDK, therefore it can always happen that we want to implement a new type, e.g. float, in a future release. Now if an SDK user handles all cases of the attribute value, therefore not having to add a default statement, it could result in unhandled cases.

But the Swift compiler developers considered this by offering the @unknown default case which may be added for Swift 5 projects, and must be added when using Swift 6:

Swift 5 warning for unknown cases
let value: Value = ...
switch value {
case .boolean(let val):
    // val is true or false
case .integer(let val):
    // val is an integer
case .string(let val):
    // val is a String
@unknown default:
    // ✅ handles all future cases
}

One alternative is attributing our enum as @frozen, indicating that the enum will never change in future versions. While it makes sense for enums like e.g. CoordinateAxis having only vertical and horizontal axes and never anything else, it's not suitable for our evolving protocol definitions.

Conclusion

Across this two-part series, we've explored how Swift's type system can transform API design from "hope it works" to "guaranteed to work."

The result is a Metrics API where:

  • Invalid values won't compile, catching mistakes before they ship
  • The compiler autocompletes exactly what you need
  • Custom types are first-class citizens
  • Future SDK updates won't break your code

But every innovation comes with trade-offs: This API is Swift-only, so Objective-C projects can't use it directly right now (though you can create a wrapper). We're already working on an Objective-C companion for a future release, so keep an eye on that.

In the end, we believe this is the direction Swift SDKs should go: making the right thing easy and the wrong thing impossible.

Try It Out

The Metrics API is now available in sentry-cocoa v9.4.0 and we'd love to hear what you think:

If you made it this far, you're exactly the kind of developer who appreciates well-designed APIs. Feel free to reach out on X or Bluesky with your thoughts, questions, or your own Swift API design stories.