OpenTelemetry Metrics for Swift

This document covers the OpenTelemetry Metrics API for Swift, including how to create instruments (Counter, Histogram, Gauge) and configure them with labels.

Prerequisites

Before you start, make sure you have configured OpenTelemetry Swift to export data to Uptrace.

If you are not familiar with metrics terminology like timeseries or additive/synchronous/asynchronous instruments, read the introduction to OpenTelemetry Metrics first.

Getting started

To get started with metrics, you need a MeterProvider which you can use to create meters:

swift
import OpenTelemetryApi

let meter = OpenTelemetry.instance.meterProvider.get(
    instrumentationName: "MyApp",
    instrumentationVersion: "1.0.0"
)

Using the meter, you can create instruments to measure performance. A simple Counter looks like this:

swift
let counter = meter.createIntCounter(name: "app.requests_total")

// Increment the counter
counter.add(value: 1, labels: ["method": "GET", "status": "200"])

Counter

Counter is a synchronous instrument that measures additive non-decreasing values.

swift
// Create an integer counter
let requestCounter = meter.createIntCounter(name: "http.requests_total")

// Increment by 1
requestCounter.add(value: 1, labels: ["method": "GET", "endpoint": "/api/users"])

// Increment by more
requestCounter.add(value: 5, labels: ["method": "POST", "endpoint": "/api/orders"])

Counter with labels

Labels allow you to slice and filter metrics:

swift
let cacheCounter = meter.createIntCounter(name: "cache.operations_total")

func cacheOperation(hit: Bool) {
    let result = hit ? "hit" : "miss"
    cacheCounter.add(value: 1, labels: ["result": result])
}

// Usage
cacheOperation(hit: true)   // cache.operations_total{result="hit"} += 1
cacheOperation(hit: false)  // cache.operations_total{result="miss"} += 1

DoubleCounter

For fractional values, use DoubleCounter:

swift
let dataTransferred = meter.createDoubleCounter(name: "network.bytes_transferred")

dataTransferred.add(value: 1024.5, labels: ["direction": "outbound"])

UpDownCounter

UpDownCounter measures values that can increase or decrease:

swift
let activeConnections = meter.createIntUpDownCounter(name: "connections.active")

// Connection opened
activeConnections.add(value: 1, labels: ["type": "websocket"])

// Connection closed
activeConnections.add(value: -1, labels: ["type": "websocket"])

Histogram

Histogram records a distribution of values:

swift
let requestDuration = meter.createDoubleHistogram(name: "http.request_duration_ms")

// Record request duration
func handleRequest() {
    let startTime = Date()

    // Process request...

    let duration = Date().timeIntervalSince(startTime) * 1000
    requestDuration.record(value: duration, labels: ["method": "GET", "endpoint": "/api/users"])
}

Custom histogram boundaries

Configure histogram bucket boundaries for your use case:

swift
let histogram = meter.createDoubleHistogram(
    name: "http.response_size_bytes",
    boundaries: [100, 500, 1000, 5000, 10000, 50000]
)

Observable instruments

Observable instruments report values when the metrics are collected, rather than when they change.

ObservableGauge

For values that are sampled at collection time:

swift
// Register a gauge that reports memory usage
let _ = meter.createIntObservableGauge(name: "process.memory_bytes") { observer in
    let memoryUsage = getMemoryUsage()
    observer.observe(value: memoryUsage, labels: ["type": "heap"])
}

func getMemoryUsage() -> Int {
    var info = mach_task_basic_info()
    var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size) / 4
    let result = withUnsafeMutablePointer(to: &info) {
        $0.withMemoryRebound(to: integer_t.self, capacity: 1) {
            task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
        }
    }
    return result == KERN_SUCCESS ? Int(info.resident_size) : 0
}

ObservableCounter

For monotonically increasing values read at collection time:

swift
let _ = meter.createIntObservableCounter(name: "process.cpu_time_ms") { observer in
    let cpuTime = getCPUTime()
    observer.observe(value: cpuTime, labels: [:])
}

ObservableUpDownCounter

For values that can increase or decrease, read at collection time:

swift
let _ = meter.createIntObservableUpDownCounter(name: "queue.size") { observer in
    observer.observe(value: messageQueue.count, labels: ["queue": "main"])
}

Complete example

Here's a complete example demonstrating various metrics patterns:

swift
import Foundation
import OpenTelemetryApi
import OpenTelemetrySdk

class MetricsExample {
    private let meter: Meter
    private let requestCounter: IntCounter
    private let requestDuration: DoubleHistogram
    private let activeRequests: IntUpDownCounter

    init() {
        self.meter = OpenTelemetry.instance.meterProvider.get(
            instrumentationName: "MyApp",
            instrumentationVersion: "1.0.0"
        )

        // Create instruments
        self.requestCounter = meter.createIntCounter(name: "http.requests_total")
        self.requestDuration = meter.createDoubleHistogram(name: "http.request_duration_ms")
        self.activeRequests = meter.createIntUpDownCounter(name: "http.requests_active")

        // Register observable gauge for memory
        let _ = meter.createIntObservableGauge(name: "process.memory_bytes") { observer in
            observer.observe(value: self.getMemoryUsage(), labels: [:])
        }
    }

    func handleRequest(method: String, endpoint: String) async {
        let labels = ["method": method, "endpoint": endpoint]

        // Track active requests
        activeRequests.add(value: 1, labels: labels)
        defer { activeRequests.add(value: -1, labels: labels) }

        let startTime = Date()

        // Simulate request processing
        try? await Task.sleep(nanoseconds: UInt64.random(in: 10_000_000...100_000_000))

        let duration = Date().timeIntervalSince(startTime) * 1000

        // Record metrics
        requestCounter.add(value: 1, labels: labels.merging(["status": "200"]) { $1 })
        requestDuration.record(value: duration, labels: labels)
    }

    private func getMemoryUsage() -> Int {
        var info = mach_task_basic_info()
        var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size) / 4
        let result = withUnsafeMutablePointer(to: &info) {
            $0.withMemoryRebound(to: integer_t.self, capacity: 1) {
                task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
            }
        }
        return result == KERN_SUCCESS ? Int(info.resident_size) : 0
    }
}

// Usage
@main
struct App {
    static func main() async {
        // Configure OpenTelemetry first (see index.md)

        let metrics = MetricsExample()

        // Simulate traffic
        for _ in 0..<10 {
            await metrics.handleRequest(method: "GET", endpoint: "/api/users")
            await metrics.handleRequest(method: "POST", endpoint: "/api/orders")
        }
    }
}

Best practices

Use meaningful metric names

Follow the naming conventions:

swift
// Good: descriptive with units
meter.createDoubleHistogram(name: "http.request_duration_ms")
meter.createIntCounter(name: "db.queries_total")
meter.createIntObservableGauge(name: "process.memory_bytes")

// Bad: unclear names
meter.createDoubleHistogram(name: "duration")
meter.createIntCounter(name: "count")

Use consistent labels

swift
// Define label keys as constants
struct MetricLabels {
    static let method = "method"
    static let endpoint = "endpoint"
    static let status = "status"
}

requestCounter.add(value: 1, labels: [
    MetricLabels.method: "GET",
    MetricLabels.endpoint: "/api/users",
    MetricLabels.status: "200"
])

Limit label cardinality

Avoid labels with high cardinality (many unique values):

swift
// Bad: user_id has unbounded cardinality
counter.add(value: 1, labels: ["user_id": userId])

// Good: bounded cardinality
counter.add(value: 1, labels: ["user_type": userType])

What's next?