Skip to content

OpenTelemetry Integration

Gleb Tarasov edited this page Jun 25, 2026 · 1 revision

OpenTelemetry Integration

PerformanceSuiteOTel is an additional library that bridges PerformanceSuite metrics to the OpenTelemetry pipeline — most signals as spans, view-controller leaks as log records — so you can route performance data through any OTel-compatible backend (Embrace, Honeycomb, an in-house OTLP collector, …) without writing a custom bridge. It depends on PerformanceSuite and on opentelemetry-swift-core's OpenTelemetryApi; it does not pull in the OTel SDK.

Installation

Swift Package Manager

Add PerformanceSuiteOTel as a target dependency alongside PerformanceSuite. The package URL is the same one you already use:

https://github.com/bookingcom/perfsuite-ios

CocoaPods

pod 'PerformanceSuite/OTel'

The OTel subspec depends on OpenTelemetry-Swift-Api (the CocoaPods spec name; the imported Swift module name is OpenTelemetryApi).

Standalone usage

OTelInstrumenter<Screen, Fragment> conforms to all eight PerformanceSuite receiver protocols and can be passed wherever the library expects a receiver. The simplest setup uses it as the only receiver:

import PerformanceSuite
import PerformanceSuiteOTel

let otel = OTelInstrumenter<PerformanceScreen, PerformanceFragment>()

let config: Config = [
    .startupTime(otel),
    .screenLevelTTI(otel),
    .screenLevelRendering(otel),
    .appLevelRendering(otel),
    .hangs(otel),
    .watchdogTerminations(otel),
    .fragmentTTI(otel),
    .viewControllerLeaks(otel),
]
try PerformanceMonitoring.enable(config: config)

Combining OTel with custom receivers

If you already have a custom receiver (e.g. an analytics pipeline) and want to fan signals out to both that receiver and OTel, wrap each one in the matching Multi*Receiver from PerformanceSuite core:

let otel = OTelInstrumenter<PerformanceScreen, PerformanceFragment>()
let custom = MyCustomReceiver()
let screenId: (UIViewController) -> PerformanceScreen? = { vc in
    (vc as? PerformanceTrackableScreen)?.performanceScreen
}

let config: Config = [
    .startupTime(MultiStartupTimeReceiver(receivers: [custom, otel])),
    .screenLevelTTI(MultiTTIMetricsReceiver(
        screenIdentifier: screenId,
        receivers: [custom, otel]
    )),
    .hangs(MultiHangsReceiver(receivers: [custom, otel])),
    .viewControllerLeaks(
        [custom, otel],
        shouldTrack: { viewController in
            // Chain-wide opt-out: returning false short-circuits the
            // observer's dispatch entirely, so neither the custom receiver
            // nor OTel sees an excluded view controller. Use this to keep
            // squeak-style and OTel emissions in lockstep.
            !(viewController is UINavigationController)
        }
    ),
    // ...
]

The viewControllerLeaks(_:shouldTrack:) convenience wraps MultiViewControllerLeaksReceiver, whose optional shouldTrack predicate gates dispatch for all children at once. The predicate is invoked once per leak, immediately before any child receiver is called; returning false skips the dispatch so the chain stays in lockstep.

iOS 16+ for OTelInstrumenter and the three generic Multi receivers. OTelInstrumenter, MultiTTIMetricsReceiver, MultiRenderingMetricsReceiver, and MultiFragmentTTIMetricsReceiver rely on constrained-existential any P<X> (runtime support shipped with iOS 16, SE-0353): the Multi receivers store [any P<X>] arrays, and the screen / fragment / rendering observers resolve a live receiver via an as? any Live…Receiver<…> cast. They are gated with @available(iOS 16.0, *). The other five Multi*Receiver types (including MultiViewControllerLeaksReceiver) and the rest of the core PerformanceSuite framework work on iOS 15+.

View-controller leak log records

OTelInstrumenter emits view-controller leaks as OTel log records rather than spans — leak detection is a point-in-time event with no meaningful duration, and a log with severity = WARN and body = "view_controller_leak" is the right semantic shape. The record carries:

Attribute Value
vc.class_name String(describing: type(of: viewController)). For UIHostingControllers (anything conforming to RootViewIntrospectable), the introspected SwiftUI root view's type is used instead, so the record carries the meaningful user-facing type rather than the generic-mangled UIHostingController<…>.
vc.identifier viewController.description (the standard NSObject <MyClass: 0x…> form).
app.startup.prewarmed true if the app was started by iOS pre-warming, otherwise false. Mirrors the startup span's policy.

Records are emitted via OpenTelemetry.instance.loggerProvider, lazily resolved at first emission (see Provider resolution below).

Host attribute enrichment

OTelInstrumenter.init accepts an optional attributeProvider: closure that is invoked once per emission with the matching PerformanceSuiteSignalContext. The dictionary it returns is merged onto the resulting span (or log record) — useful for adding host-app context (experiment buckets, low-power-mode, …) that the SDK itself doesn't know about.

let otel = OTelInstrumenter<PerformanceScreen, PerformanceFragment>(
    spanNamePrefix: "myapp",
    attributeProvider: { context in
        // Exhaustive switch — the compiler will flag any future signal
        // kind, so enrichment stays complete by construction.
        switch context {
        case .watchdogTermination, .fatalHang:
            // Use cross-launch state — the previous session's experiments,
            // for example.
            return previousSessionExperimentBuckets()
        case .nonFatalHang, .viewControllerLeak:
            // In-session events; current-session state is appropriate.
            return currentSessionExperimentBuckets()
        case .startup(let data):
            // `.startup` carries the SDK's `StartupTimeData` payload directly,
            // so closures can read every public field without an upstream PR
            // — for example, weight prewarmed startups differently or read
            // `data.totalTime.milliseconds` on the host side.
            var attrs: [String: AttributeValue] = [
                "app.low_power_mode": .bool(ProcessInfo.processInfo.isLowPowerModeEnabled),
            ]
            if data.appStartInfo.appStartedWithPrewarming {
                attrs["app.startup.bucket"] = .string("prewarmed")
            }
            return attrs
        case .appRendering:
            return ["app.low_power_mode": .bool(ProcessInfo.processInfo.isLowPowerModeEnabled)]
        case .screenTTI, .screenRendering, .fragmentTTI:
            return [:]
        }
    }
)

PerformanceSuiteSignalContext is a hybrid enum: .startup / .fatalHang / .nonFatalHang / .watchdogTermination / .viewControllerLeak cases carry the SDK's own public payload type directly (StartupTimeData, HangInfo, WatchdogTerminationData, UIViewController); TTI / rendering cases carry small generic-erased projection structs (ScreenContext, FragmentContext, AppRenderingContext). Pattern-bind the payload on the cases that have one — for example case .fatalHang(let info): exposes every public field of HangInfo (callStack, duration, appRuntimeInfo, detectedAt, sessionId, …) without requiring an upstream PR to widen a curated projection.

Splitting fatal and non-fatal hang dispatch into distinct enum cases makes the emitter pick the right one at construction time — mis-threading fatality is a compile-time error rather than a silent default. Host enrichment closures get the same guarantee through exhaustive switches.

SDK-set keys are protected. Each per-signal merge filters host attributes against the universe of attribute keys the SDK reserves for that signal (OTelSDKKeys.startup, OTelSDKKeys.hang, OTelSDKKeys.viewControllerLeak, …). A host attribute matching one of those keys is silently dropped at the merge boundary, so semantic-convention values like screen.tti.ms, app.state, hang.duration.ms, or vc.class_name can never be overwritten by host code.

Provider resolution

By default, OTelInstrumenter resolves both OpenTelemetry.instance.tracerProvider and OpenTelemetry.instance.loggerProvider lazily — at every emission, not at instantiation. This is intentional: the host app's OTel SDK (Embrace, vendor SDK, …) typically registers the global providers during its own startup, which may run after PerformanceMonitoring.enable(...). Resolving lazily means signals emitted before the SDK is ready fall through to the no-op default providers (silently dropped, no crash), and every emission afterward uses the real providers.

You can also inject explicit providers at construction time — useful for tests, multi-tenant setups, or routing PerformanceSuite signals to a different tracer / logger than the rest of the app:

let otel = OTelInstrumenter<PerformanceScreen, PerformanceFragment>(
    tracerProvider: customTracerProvider,
    loggerProvider: customLoggerProvider,
    instrumentationName: "perfsuite-ios",        // default, included on every span / log record
    instrumentationVersion: "1.7.0"              // optional
)

Filtering emissions

OTelInstrumenter.init accepts an optional shouldEmit: closure invoked per emission. Returning false drops the signal before any host work (no provider attributes evaluated, no tracer or logger resolved). For completed spans and the start-gated live spans (screen / fragment TTI, screen rendering) no span is built at all; for deferred-context live spans (startup, hangs, app-rendering) see the finalize note below. Pattern-matching against the typed signal payload reads naturally:

let otel = OTelInstrumenter<PerformanceScreen, PerformanceFragment>(
    shouldEmit: { context in
        switch context {
        case .startup(let data):
            // Drop prewarmed launches — their timings aren't comparable to cold starts.
            return !data.appStartInfo.appStartedWithPrewarming
        case .screenTTI(let screen):
            return screen.screenName != "debug_menu"
        default:
            return true
        }
    }
)

shouldEmit is independent of attributeProvider: a signal that the gate suppresses is never handed to the provider either. Use shouldEmit for binary keep-or-drop decisions and attributeProvider for adding host context to signals you do want to emit.

For live spans (TTI, fragment TTI, screen rendering — see "Live spans" below) shouldEmit evaluates at start time, since the receiver context is fully known by then. A rejected signal never reaches tracer() — no span is created.

For signals whose context completes only at finalize (startup before StartupTimeData arrives, app-rendering before sessionEndedAt arrives, hangs before fatal-vs-non-fatal is known), the live span is started unconditionally and the gate runs at finalize. A rejected span is closed with Status.error("shouldEmit_rejected") so it can be filtered out before reaching your backend. If you'd rather not see those spans in the downstream pipeline, install a wrapping SpanExporter that filters them out. SpanProcessor.onEnd is informational and can't actually suppress a span — the place to drop is the export step:

final class FilteringSpanExporter: SpanExporter {
    private let underlying: SpanExporter
    init(_ underlying: SpanExporter) { self.underlying = underlying }

    func export(spans: [SpanData], explicitTimeout: TimeInterval? = nil) -> SpanExporterResultCode {
        // The rejected-span sentinel set by perfsuite-ios when shouldEmit
        // refuses a late-context live span at finalize.
        let kept = spans.filter { span in
            guard case let .error(description) = span.status else { return true }
            return description != "shouldEmit_rejected"
        }
        return kept.isEmpty ? .success : underlying.export(spans: kept, explicitTimeout: explicitTimeout)
    }

    func flush(explicitTimeout: TimeInterval? = nil) -> SpanExporterResultCode {
        underlying.flush(explicitTimeout: explicitTimeout)
    }
    func shutdown(explicitTimeout: TimeInterval? = nil) {
        underlying.shutdown(explicitTimeout: explicitTimeout)
    }
}

// Install at TracerProvider build time, wrapping whatever real exporter you use:
//   let exporter = FilteringSpanExporter(realExporter)
//   tracerProvider.addSpanProcessor(BatchSpanProcessor(spanExporter: exporter))

The actual API names may differ slightly across opentelemetry-swift versions — adjust to your pinned major.

App rendering: one span per app-foreground session

OTelInstrumenter emits one OTel app-rendering span per app-foreground session. The span is a live span opened on UIApplication.didBecomeActiveNotification and ended on UIApplication.didEnterBackgroundNotification. While the session is running, every throttled appRenderingMetricsReceived(metrics:) chunk updates the running counters AND re-applies them to the live span via setAttribute, so the latest snapshot is always on the span. At the clean boundary the span ends with Status.unset, the cumulative rendering counters (rendering.dropped_frames, rendering.freeze_time.ms, rendering.total_frames, rendering.session_duration.ms), and app.session.duration.ms equal to the wall-clock sessionEndedAt - sessionStartedAt.

didEnterBackground is the clean boundary, not willResignActive — the latter fires for transient interruptions (control-centre swipes, incoming-call banners, app-switcher previews) that would otherwise split a single user session into many tiny spans.

For sessions that don't end with a clean didEnterBackground (crash, OOM, watchdog kill, force-quit, debugger stop, simulator stop), the live span is never ended in-process. To let a backend end it, inject autoTerminationAttribute: — a (key, value) stamped at start on every unclean-exit live span (app-rendering, startup, screen / fragment TTI, screen rendering). The hang live span deliberately omits it: a fatal hang already has an authoritative next-launch record (fatalHangReceived), so an auto-terminated orphan would double-count it. The library names no vendor; for Embrace pass the canonical code:

let otel = OTelInstrumenter<PerformanceScreen, PerformanceFragment>(
    autoTerminationAttribute: (key: "emb.auto_termination.code", value: "user_abandon")
)

EmbraceSpanProcessor reads emb.auto_termination.code on Span.onStart, registers the span, and on session-end recovery ends the orphaned span with Status.error("user_abandon") and emb.error_code = "user_abandon" (matching SpanErrorCode.userAbandon.rawValue). The injected key is reserved so a host attributeProvider can't overwrite it. With nothing injected (the default), the library stays vendor-neutral and an unclean-exit span is simply never ended.

A clean session with zero dropped frames still emits a span (with app.session.duration.ms set and zero rendering counters) — it's signal, not noise. Backend dashboards use this to compute the "fraction of perfectly-rendered sessions" metric.

Hangs and previous-session correlation

OTelInstrumenter opens a live app-hang span on hangStarted(info:) (anchored on info.detectedAt) and finalises it on nonFatalHangReceived(info:) with the final duration. Fatal hangs detected on the next launch are emitted as completed spans (the corresponding hangStarted ran in a previous, now-dead process and can never be paired up).

HangInfo exposes two optional fields that let backends correlate a fatal hang detected on the next launch with the previous session that produced it:

  • HangInfo.detectedAt: Date? — wall-clock moment of detection, captured synchronously inside the hang reporter when the HangInfo is constructed. When set, OTelInstrumenter uses (detectedAt, detectedAt + duration) as the hang span's window.

  • HangInfo.sessionId: String? — application-defined session identifier. Plumbed through PerformanceMonitoring.enable(sessionIdProvider:):

try PerformanceMonitoring.enable(
    config: config,
    sessionIdProvider: { Embrace.client?.currentSessionId() }
)

The closure is invoked synchronously at hang detection. When non-nil, OTelInstrumenter surfaces the captured value as the app.session.id attribute on hang spans (reserved in OTelSDKKeys.hang so a host attributeProvider cannot overwrite it).

Both fields decode as nil when absent from persisted JSON.

Live spans: started/ended via Live* sub-protocols

For consumers that want OTel spans to exist during a measurement (rather than as completed spans recorded retroactively at the end), each receiver protocol has an opt-in Live* sub-protocol. A receiver that wants live spans conforms to the sub-protocol (alongside the base protocol); the SDK reporter resolves it at runtime via an as? cast and drives the measurement's started / ended lifecycle. A receiver that conforms only to the base protocol keeps getting the completed *Received callback unchanged — fully backward compatible.

Liveness is a conformance, not a defaulted hook, so dispatch is unambiguous and never shadowed by a default when the receiver is used as any P or behind a Multi*Receiver. The screen / fragment / rendering sub-protocols carry the screen/fragment associated type, so the reporter resolves them with a constrained-existential as? any Live…Receiver<…> cast — that needs iOS 16 (SE-0353). Startup and app-rendering have no associated type and resolve on iOS 15+, but OTelInstrumenter as a whole is @available(iOS 16).

// The handle returned from a `started` call and passed back to the matching `ended`.
public protocol MeasurementHandle: AnyObject {
    func cancel()   // invoked when the measurement is abandoned; safe to call after `ended`
}

public protocol LiveTTIMetricsReceiver<ScreenIdentifier>: TTIMetricsReceiver {
    func screenTTIMeasurementStarted(screen: ScreenIdentifier) -> (any MeasurementHandle)?
    func screenTTIMeasurementEnded(metrics: TTIMetrics, screen: ScreenIdentifier,
                                   context: (any MeasurementHandle)?)
}

public protocol LiveRenderingMetricsReceiver<ScreenIdentifier>: RenderingMetricsReceiver {
    // note the synchronously-captured Date anchor
    func screenRenderingStarted(screen: ScreenIdentifier, sessionStarted: Date) -> (any MeasurementHandle)?
    func screenRenderingEnded(metrics: RenderingMetrics, screen: ScreenIdentifier,
                              context: (any MeasurementHandle)?)
}

public protocol LiveFragmentTTIMetricsReceiver<FragmentIdentifier>: FragmentTTIMetricsReceiver {
    func fragmentTTIMeasurementStarted(fragment: FragmentIdentifier) -> (any MeasurementHandle)?
    func fragmentTTIMeasurementEnded(metrics: TTIMetrics, fragment: FragmentIdentifier,
                                     context: (any MeasurementHandle)?)
}

public protocol LiveStartupTimeReceiver: StartupTimeReceiver {
    func startupMeasurementStarted() -> (any MeasurementHandle)?
    func startupMeasurementEnded(_ data: StartupTimeData, context: (any MeasurementHandle)?)
}

public protocol LiveAppRenderingMetricsReceiver: AppRenderingMetricsReceiver {
    func appRenderingSessionStarted(at startedAt: Date)
    func appRenderingSessionEnded()
}

A live receiver implements started to build an in-progress span and return a MeasurementHandle wrapping it; the SDK reporter holds the handle across the measurement window and either passes it back via ended (success path) or invokes cancel() on it (abandonment path: the screen was ignored, the app went to background mid-measurement, or the tracked object was deallocated before its terminal callback fired).

OTelInstrumenter is a live receiver out of the box — it conforms to every Live* sub-protocol, opens spans via the OTel tracer at start time, and finalises them at end time with the timing-derived attributes (screen.tti.ms, app.session.duration.ms, etc.). The hang live span uses the existing hangStarted(info:) / nonFatalHangReceived(info:) callbacks for the same shape.

App-rendering's lifecycle is driven entirely by AppRenderingReporter on PerformanceMonitoring.consumerQueueappRenderingSessionStarted(at:) on didBecomeActive, per-chunk appRenderingMetricsReceived(metrics:), then appRenderingSessionEnded() on didEnterBackground / willTerminate — so session start and end stay FIFO-ordered on one serial queue (a fast background→foreground can't open a new session before the previous one closes). The at: start instant is captured on the main thread and passed through for a precise anchor.

The screen-rendering started callback receives a Date (sessionStarted:) captured synchronously inside RenderingObserver.afterViewDidAppear before the queue hop. This anchors the live span's setStartTime(time:) precisely on the visible-frame moment instead of the cumulative consumer-queue dispatch latency. The same anchor flows through RenderingMetrics.sessionStarted (a new field summed by the existing + operator with "earlier non-nil wins" semantics) into the completed-span path, so even plain (non-Live) receivers get wall-clock-correct screen-rendering spans.

Semantic conventions

Span names, log bodies, and attribute keys are exposed as constants on OTelSemanticConventions (e.g. OTelSemanticConventions.SpanName.appStartup, OTelSemanticConventions.Attribute.screenTTIMs, OTelSemanticConventions.LogBody.viewControllerLeak). Backend dashboards can target these without grepping the source. The full list:

  • Span names: app-startup, screen-tti.<name>, fragment-tti.<name>, screen-rendering.<name>, app-rendering, app-hang, app-watchdog-termination.
  • Log bodies: view_controller_leak.
  • Span attributes: startup timing, screen / fragment TTI, rendering frame counts and freeze time, hang type / duration / top screen, watchdog termination state and memory. Hang spans additionally carry app.session.id when HangInfo.sessionId is supplied via the sessionIdProvider closure on PerformanceMonitoring.enable(...). App-rendering spans carry app.session.duration.ms (wall-clock didEnterBackground - didBecomeActive).
  • Log attributes (view-controller leaks): vc.class_name, vc.identifier, app.startup.prewarmed.

Clone this wiki locally