The Atatus iOS SDK can connect a request made on the device to the work it causes on your servers, so one trace covers the tap, the request, and the backend handlers. It can also span operations of your own, such as a database migration at launch or a multi-step checkout.

Tracing ships as its own product, AtatusTrace, built on the OpenTelemetry API.

Prerequisites

  • RUM is set up in your application. See iOS Monitoring Setup.
  • The backend services you call are instrumented with an Atatus APM agent, or with OpenTelemetry.

Connect requests to backend traces

Trace propagation is driven by the list of hosts you own. Set it on the RUM configuration, and every matching request carries trace headers that your backend reads:

copy
icon/buttons/copy
import AtatusRUM

RUM.enable(
    with: RUM.Configuration(
        uiKitViewsPredicate: DefaultUIKitRUMViewsPredicate(),
        urlSessionTracking: .init(
            firstPartyHostsTracing: .trace(
                hosts: ["api.example.com", "payments.example.com"],
                sampleRate: 20
            )
        )
    )
)

The resource recorded for that request carries the same trace and span IDs that went out in the headers, which is what lets the dashboard join the mobile resource to the backend trace.

A plain host also matches its subdomains, so example.com covers api.example.com. A pattern such as *.example.com matches subdomains but not example.com itself.

Sampling

sampleRate is the percentage of first-party requests that carry a kept trace, and it combines with the RUM session sample rate. With a session rate of 80 and a trace rate of 20, about 16 percent of requests are traced. The decision is consistent within a session, so you do not get half a journey.

With the default traceControlInjection of .sampled, a request that is not sampled carries no trace headers at all. Set .all when your backend needs a trace context on every request, in which case unsampled requests are propagated with a "do not keep" decision:

copy
icon/buttons/copy
urlSessionTracking: .init(
    firstPartyHostsTracing: .trace(
        hosts: ["api.example.com"],
        sampleRate: 20,
        traceControlInjection: .all
    )
)

Headers that are injected

.trace(hosts:) injects both the Atatus headers and W3C trace context:

Format Headers
.atatus x-atatus-trace-id, x-atatus-parent-id, x-atatus-sampling-priority, x-atatus-tags, x-atatus-origin, baggage
.tracecontext traceparent, tracestate, baggage
.b3 b3
.b3multi X-B3-TraceId, X-B3-SpanId, X-B3-Sampled, X-B3-ParentSpanId

To choose the formats per host, for example when a service only understands B3, use .traceWithHeaders:

copy
icon/buttons/copy
urlSessionTracking: .init(
    firstPartyHostsTracing: .traceWithHeaders(
        hostsWithHeaders: [
            "api.example.com": [.atatus, .tracecontext],
            "legacy.example.com": [.b3multi]
        ],
        sampleRate: 20
    )
)
Note:

Requests from a native application are not subject to CORS, so there is nothing to allow on the browser side. Do check that a proxy, CDN, or WAF in front of your service is not stripping the x-atatus-*, traceparent, or baggage headers, because that is the usual reason a trace stops at the edge.

Record the mobile request as a span

RUM propagation gives you the backend side of the trace. To also see the request as a span from the device, enable AtatusTrace with the same hosts:

copy
icon/buttons/copy
import AtatusTrace

Trace.enable(
    with: Trace.Configuration(
        urlSessionTracking: .init(
            firstPartyHostsTracing: .trace(hosts: ["api.example.com"])
        )
    )
)

Each first-party request then produces a urlsession.request client span carrying the URL, method, and status code, using the same trace context RUM injected. RUM remains the owner of header injection, so there is one sampling decision and one trace context per request.

Note:

Requests to a host that is not listed in firstPartyHostsTracing produce no span and no headers. List only the hosts you own, because trace headers are meaningless to a third-party API and expose your trace IDs to it.

Trace configuration

Option Default Purpose
sampleRate 100 Percentage of spans from the tracer that are kept
service Atatus.Configuration.service Overrides the service name on spans
tags nil Tags attached to every span
urlSessionTracking nil Emits a span per first-party URLSession request
bundleWithRumEnabled true Attaches the RUM session, view, and action to each span
networkInfoEnabled false Adds connection type and carrier information to each span
eventMapper nil Modifies spans before they are sent
customEndpoint nil Sends spans to a different intake URL

urlSessionTracking also takes redactedStatusCodes, which replaces the resource name with the status code so that error responses group together. It defaults to [404], and an empty set turns the behavior off.

Span your own operations

Get the tracer, start a span, and finish it:

copy
icon/buttons/copy
import AtatusTrace

let tracer = Tracer.shared()

let span = tracer.startSpan(operationName: "load-products")
span.setTag(key: SpanTags.resource, value: "/v2/products")
span.setTag(key: "items.count", value: 42)
span.finish()

A span started while another is active becomes its child, which is how you build a tree without passing contexts around:

copy
icon/buttons/copy
let checkout = tracer.startSpan(operationName: "checkout").setActive()

let validate = tracer.startSpan(operationName: "validate-cart")
validate.finish()

checkout.finish()

You can also name the parent explicitly with childOf: span.context, or start a span that is always a trace root:

copy
icon/buttons/copy
let root = tracer.startRootSpan(operationName: "app-migration", customSampleRate: 100)
root.keepTrace()   // or root.dropTrace()
root.finish()

SpanTags holds the tag names the dashboard understands: resource, operation, service, manualKeep, and manualDrop.

Record an error on a span

copy
icon/buttons/copy
do {
    try loadProducts()
} catch {
    span.setError(error)
}

span.log(fields: [OTLogFields.message: "cache miss", "retries": 2])
Note:

setError and log write through the logging product. Without Logs.enable() the span is still sent, but the error details and logs attached to it are dropped with a console warning. Enable Log Collection if you use either.

Use the OpenTelemetry API instead

If your code is already written against OpenTelemetry, register the provider and use the standard API. Both paths are backed by the same tracer:

copy
icon/buttons/copy
import OpenTelemetryApi
import AtatusTrace

OpenTelemetry.registerTracerProvider(tracerProvider: OTelTracerProvider())

let tracer = OpenTelemetry.instance.tracerProvider.get(
    instrumentationName: "",
    instrumentationVersion: nil
)

let span = tracer.spanBuilder(spanName: "load-products").startSpan()
span.setAttribute(key: "items.count", value: .int(42))
span.end()

The instrumentation name, version, and attributes you pass to get are ignored, so set global tags through Trace.Configuration(tags:). The Atatus-specific helpers, such as keepTrace(), dropTrace(), setError, and setActive(), are only on the Tracer.shared() path.

Propagate trace context yourself

For a client that is not URLSession, inject the headers by hand:

copy
icon/buttons/copy
import AtatusTrace

let span = Tracer.shared().startSpan(operationName: "network-request")

let writer = HTTPHeadersWriter(traceContextInjection: .sampled)
Tracer.shared().inject(spanContext: span.context, writer: writer)

var request = URLRequest(url: URL(string: "https://api.example.com/v2/products")!)
writer.traceHeaderFields.forEach { request.setValue($1, forHTTPHeaderField: $0) }

// Perform the request, then
span.finish()

Use W3CHTTPHeadersWriter() for traceparent and tracestate, or B3HTTPHeadersWriter(injectEncoding: .single) for B3. HTTPHeadersWriter requires the traceContextInjection argument, while the W3C and B3 writers default to .sampled.

Correlate logs and traces

A log written while a span is active carries that trace and span, because bundleWithTraceEnabled is on by default for every logger. Nothing else is required. See Log Collection.

Next steps