- Published on
Leveraging Swift's Type-Safety in the New Metrics Feature
- Authors

- Name
- Phil Niedertscheider
With the release of Apple / Cocoa SDK v9.2.0, we're excited to share not just the new experimental Metrics feature, but the engineering thinking behind it. Already available in our Python, JavaScript, and Go SDKs, Metrics lets you collect custom measurements to gain deeper insights into your app:
// Track how many users completed checkout
SentrySDK.metrics.count(
key: "checkout.completed",
value: 1,
attributes: [
"payment_method": "apple_pay",
"cart_items": 3
]
)
// Monitor your in-memory cache size
SentrySDK.metrics.gauge(
key: "cache.size_mb",
value: 42.5,
attributes: [
"cache_name": "image_cache"
]
)
// Measure how long image processing takes
SentrySDK.metrics.distribution(
key: "image.processing_time",
value: 187.5,
unit: .millisecond
)
While the proof-of-concept was done weeks ago, most of our effort went into designing the public API - the interface our SDK users interact with daily, and one we can't easily change once released.
In this post, I'll walk you through our design process and the Swift features that made it possible. To name a couple of topics we are going to cover, here's what you'll take away:
- Protocol extensions as a workaround for Swift's compiler limitations
- Enums with associated values for extended information
- Using
ExpressibleByStringLiteralto convert literals straight into types - Forward-compatible enum design that won't break when you add new cases
Join me on this deep-dive and let's get straight into it.
Three Important Methods
Let's start at the beginning.
From a user perspective, the most important parts are the methods used to capture metrics. To enable this, the SDK needs to offer a SentrySDK.metrics object with the three methods .count(..), .gauge(..) and .distribution(..), each with a key and value parameter.
This brings up the first opportunity where we decided against surfacing a concrete type, and instead adopt it using a protocol (also known as "interfaces" in other programming languages), allowing us to easily refactor otherwise public types in the future, reducing the need for breaking changes in the future.
This brings up the first benefit of using Swift. We use Double for the gauge and distribution metrics to capture values with floating point precision, including negative values. But for counter metrics we realized that the count is always a whole number and never negative, resulting in the first decision of using unsigned integers UInt for counter metrics.
public protocol SentryMetricsApiProtocol {
func count(key: String, value: UInt)
func distribution(key: String, value: Double)
func gauge(key: String, value: Double)
}
Omit Parameter With Default Values
Looking at our technical specifications for Metrics we notice one detail in the requirements:
For
countermetrics: the count to increment by (should default to 1)
This means it must be possible for SDK users to capture a counter metric without having to explicitly define a value, falling back to 1 as a default. Commonly this is solved by using a default value in the method signature, i.e. func count(key: String, value: UInt = 1) allowing an invocation with count(key: "my-key") and count(key: "my-key", value: 123).
Unfortunately Swift's protocols do not support default values in their definitions, leaving us with a build-time error:

Luckily there is a solution: Protocol Extensions.
Extensions in Swift allow adding additional logic to types, e.g. if a type has a getter for firstName and lastName an extension could add fullName returning the concatenation of the two strings.
struct Person {
let firstName: String
let lastName: String
}
extension Person {
var fullName: String {
firstName + " " + lastName
}
}
The important part to understand here is that protocol extensions only know about the signature of the protocol, therefore we can also only access methods defined in SentryMetricsApiProtocol. But this is actually all we need, as we are adding convenience overloads for our methods, allowing callers to omit the optional parameters:
public extension SentryMetricsApiProtocol {
func count(key: String, value: UInt = 1) {
// Call the implementation of the protocol which requires the value
self.count(key: key, value: value)
}
}
Great, now that we have our public API established with a default value for counters, it's time to extend it with the next useful addition: metrics units.
Metrics Units
Sentry's telemetry system has a standardized list of pre-defined units enabling server-side aggregation and data processing.
The simplest solution would be changing the API to offer a String parameter to define the unit. But, as these are standardized, we can also use Swift's enum type to offer compile-time safety and by defining the raw value to String, the compiler takes care of generating String values for each case and other boilerplate code for us:
public enum SentryUnit: String {
case nanosecond
case microsecond
case millisecond
// ... and more!
}
// Example:
let unit = SentryUnit.nanosecond
// When the compiler can infer the type of a variable, we don't need to explicitly define it again on the right-hand side:
let unit: SentryUnit = .nanosecond
As the unit parameter is optional and should also be omittable, we can leverage our protocol extension to implement it:
public protocol SentryMetricsApiProtocol {
func count(key: String, value: UInt, unit: SentryUnit?)
func distribution(key: String, value: Double, unit: SentryUnit?)
func gauge(key: String, value: Double, unit: SentryUnit?)
}
public extension SentryMetricsApiProtocol {
func count(key: String, value: UInt = 1, unit: SentryUnit? = nil) {
self.count(key: key, value: value, unit: unit)
}
func distribution(key: String, value: Double, unit: SentryUnit? = nil) {
self.distribution(key: key, value: value, unit: unit)
}
func gauge(key: String, value: Double, unit: SentryUnit? = nil) {
self.gauge(key: key, value: value, unit: unit)
}
}
// Example Usage:
// Value falls back to 1, unit is nil
SentrySDK.metrics.count(key: "network.request.count")
// Value is explicitly set to 2, unit is still nil
SentrySDK.metrics.count(key: "memory.warning", value: 2)
// Both the value and unit are set
SentrySDK.metrics.count(key: "queue.processed_bytes", value: 512, unit: .bytes)
So, how about using non-standard units?
Enums And Generic Values
While using an enum as a type-safe approach of constants, we lost a big advantage compared to pure String constants, as we are now not able to use generic units anymore. The method typing is strict and if we pass in a parameter unit, it must be a SentryUnit.
This is where Swift's Associated Values come into play, allowing us to keep using well-known enum types, but extending our new type generic with an associated custom String value:
public enum SentryUnit {
case nanosecond
case generic(String)
}
let unit = SentryUnit.generic("custom unit")
Unfortunately, this change requires us to remove the raw value conformance to the type String, resulting in the loss of compiler generated serialization:

But, this minor inconvenience can easily be resolved by implementing conformance to the Swift standard library's RawRepresentable protocol, with all unknown unit types converting from or to the enum type generic:
extension SentryUnit: RawRepresentable {
/// Maps known unit strings to their corresponding enum cases, or falls back to `.generic(rawValue)` for any unrecognized string (custom units).
public init?(rawValue: String) {
switch rawValue {
case "nanosecond":
self = .nanosecond
default:
self = .generic(rawValue)
}
}
/// Returns the string representation of the unit.
public var rawValue: String {
switch self {
case .nanosecond:
return "nanosecond"
case .generic(let value):
return value
}
}
}
Now it's easy to add more information to our metrics, e.g. by using a custom unit type "warning":
SentrySDK.metrics.count(
key: "memory.warning",
value: 2,
unit: .generic("warning")
)
Syntactic Sugar for Custom Units
Looking at the usage of the generic unit as in unit: .generic("custom") raises the idea of how we can reduce boilerplate code. We already know that if we don't use any of the pre-defined constants like .nanosecond, we always have a String value that should always be seen as a "generic" / "custom" unit (Yes, always is bold twice on purpose).
If wrapping it in SentryUnit.generic(..) (or just .generic(..)) every single time seems like repetitive boilerplate code to you, there's something we can do about it!
As a final cherry-on-top improvement opportunity for generic units, we adopt the protocol ExpressibleByStringLiteral for our enum SentryUnit. This protocol of the Swift standard library is baked into the compiler and requires us to define an additional initializer:
extension SentryUnit: ExpressibleByStringLiteral {
public init(stringLiteral value: StringLiteralType) {
self = .generic(value)
}
}
But now this small extension indicates to the compiler that literal String values can directly be converted into enums:
// ✅ Compiler converts the string to an enum with associated value
let unit: SentryUnit = "warning"
// ❌ Does not work for String variables, only literal values
let myUnit = "some value"
let unit: SentryUnit = myUnit
// ✅ String variables still need to be wrapped
let unit: SentryUnit = .generic(myUnit)
All of these additions now result in an even cleaner API with custom metric units, while still supporting pre-defined constants.
SentrySDK.metrics.count(
key: "memory.warning",
value: 2,
unit: "warning"
)
Adding Context With Attributes
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 different types. At the time of writing this blog these are the value types supported by Sentry's data processing:
stringbooleaninteger(64-bit signed integer)double(64-bit floating point number)string[]boolean[]integer[]double[]
Attributes are not a new addition to the SDK due to Metrics, as they're already used by the Logs feature released with v8.54.0 in July 2025.
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.
@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 lose compile-time checking for duplicate key literal values 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"), // ❌ Key used twice
]
)
// Usage with dictionary of attribute values
SentrySDK.metrics.count(
key: "network.request.count",
value: 1,
attributes: [
"endpoint": "/api/users",
"endpoint": "/api/users/123", // ✅ won't compile
]
)
This was enough reason to conclude, that we still want to have a dictionary of String keys with associated values.
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 actually 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, a 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 in anything. As a fallback for unknown types such as MyType, we are performing an internal conversion to String, resulting in the following serialized data:
{
"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).MyType is pretty much a useless 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 (often known as 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
CustomStringConvertiblejust 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 earlier for SentryUnit by introducing an enum with associated values: SentryAttributeContent.
(P.S. there were many rounds of renamings happening in the pull requests, from "value" to "content" etc., 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])
}
public 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 associated values 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-way to handle this? Exactly! One type protocol to rule them all.
protocol SentryAttributeValue {
var asSentryAttributeContent: SentryAttributeContent { get }
}
public 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 not even using a concrete type for the attribute value, it just accepts any type which adopted the protocol SentryAttributeValue, therefore declaring that it has a getter method or property to represent itself as a 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 {
public var asSentryAttributeContent: SentryAttributeContent {
return .string(self)
}
}
extension Bool: SentryAttributeValue {
public var asSentryAttributeContent: SentryAttributeContent {
return .boolean(self)
}
}
extension Int: SentryAttributeValue {
public var asSentryAttributeContent: SentryAttributeContent {
return .integer(self)
}
}
extension Double: SentryAttributeValue {
public var asSentryAttributeContent: SentryAttributeContent {
return .double(self)
}
}
extension Float: SentryAttributeValue {
public 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, therefore everyone can now use the metrics API as defined at the beginning of this post, supporting variables and literals:
let paymentMethod = "apple_pay" // Variables work seamlessly
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 the extension <TYPE> where <CONDITION> approach offered by Swift, to add logic to a TYPE only if a CONDITION on the typing is fulfilled.
extension Array: SentryAttributeValue where Element == Int {
public var asSentryAttributeContent: SentryAttributeContent {
.integerArray(self)
}
}
While this worked if we write the extension only for a single type, we started to hit compiler errors with multiple type extensions:

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.
extension Array: SentryAttributeValue where Element == SentryAttributeValue {
public 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, so feel free to check it out.
This worked well (for a while), as we were now able to pass in String arrays, Bool arrays, 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. 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[]
"related_items": [ProductID(), CategoryID()] // ❌ Compiler sees [Any], not [SentryAttributeValue]
]
)
As Any is a type which can not be extended nor does it have a clear representation as an attribute value, we have to remove the condition from the Array extension and have additional handling:
extension Array: SentryAttributeValue {
public 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 has to cast from arrays of Any to our known types, including handling of other types adopting the protocol and a String fallback for everything else.
Granular Control and Forwards-Compatibility
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 before they are sent to Sentry, either for data enrichment, data scrubbing and other use cases.
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 of 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. Therefore we decided to use struct data types instead with SentryMetric as our input type and SentryMetric? as a nullable return type.
While this removes compatibility with Objective-C (as struct is Swift-only), by using struct the metric is passed as an immutable copy to the beforeSendMetric closure and can not be modified directly, unless it's copied to a local variable first. We 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 is never the case.
For the type of the attributes property of the metric, we decided to expose the dictionary not as 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-cases handling.
Bringing it all together the beforeSendMetric can 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
}
During one of our 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:
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/false
default:
// do nothing
}
// Handle all cases
let value: Value = ...
switch value {
case .boolean(let val):
// val is true/false
case .integer(let val):
// val is an integer
case .string(let val):
// val is a String
}
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, this precondition could break the logic flow.
But the Swift compiler developers considered this edge case by offering the @unknown default case which can be added for Swift 5, and must be added for Swift 6:

// Handle all cases and unknown defaults
let value: Value = ...
switch value {
case .boolean(let val):
// val is true/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. This is not something we want right now, because it makes sense for enums like e.g. CoordinateAxis having only vertical and horizontal axis and never anything else, but not for our evolving protocol definitions.
Conclusion
Designing an API is easy. Designing one that catches potential issues before they become production incidents - that takes effort.
By leveraging Swift's type system, we've created a Metrics API where:
- Invalid values won't compile, resulting in early feedback.
- The compiler autocompletes to exactly what you need.
- Custom types are first-class citizens, extending the SDK to your needs.
- Proper future-proofing using
@unknown defaultand@frozenenums.
Of course, there are trade-offs - especially the most significant one: this API is Swift-only. Objective-C projects can't use it directly (though you can create a wrapper), and we're tracking Objective-C support for a future release.
We also hit Swift compiler limitations with array type inference, which is why we needed the fallback-to-String mechanism. And we can't retrofit these patterns to the existing Logs API without a breaking change.
But overall we believe this is the direction Swift SDKs should go, by making the right thing easy and the wrong thing impossible.
Try It Out
The Metrics API is available now in sentry-cocoa v9.2.0. We'd love to hear what you think:
- Found a bug or have feedback? Open an issue on GitHub
- Want to see how we implemented it? The full source code is open source
- Interested in building developer tools? We're hiring - check out our open positions
If you made it this far, you're exactly the kind of developer who appreciates well-designed APIs. I'd love to hear from you - reach out on X or Bluesky with your thoughts, questions, or your own Swift API design war stories.