epok

Instrument your mobile app

Your iOS or Android app can ship telemetry to Epok today with the standard OpenTelemetry SDKs — opentelemetry-swift and opentelemetry-android. Point them at Epok's OTLP endpoints and you get hand-instrumented spans, error events, and custom metrics from the app, in the same views as your backend — and when a mobile request hits your instrumented backend, the two sides join into one trace.

Time to first span: ~15 min · Trial: 14 days, no card · API key: app.getepok.dev → Settings → API Keys

Mobile apps must embed an INGEST-ONLY key: APKs and app bundles expose their credentials. In Settings → API Keys → Create key, select only ingest and deselect read and every other scope. Use that key for YOUR_API_KEY below. Never embed a default ingest+read key, a symbols key, or an admin key. The signed-in UI uses your session to resolve mappings; dedicated symbols:read keys are for trusted tools only.

What this is — and isn't

This recipe uses the community OpenTelemetry SDKs, rather than the Epok native SDKs. For Android, prefer Android RUM (epok-rum-otel), which captures crashes and ANRs automatically. With this recipe you get everything you instrument by hand: spans, error events, custom metrics, and backend trace correlation. This community SDK recipe has these limits:

  • No automatic crash or ANR capture — an uncaught crash that kills the process is not reliably reported by this span-only recipe. On Android, use the Epok Android SDK for automatic crash capture.
  • iOS dSYM symbolication is not supported. For Android R8 / ProGuard mappings, follow the upload step below.
  • Session replay is a separate library: see mobile session replay.

Epok's native SDKs are in Early access: Android epok-android 0.2.1 and iOS epok-ios 0.1.0 (EpokRum and EpokReplay). The iOS package is available through Swift Package Manager; see the native SDK installation. This does not add automatic crash reporting to the community recipe above. See mobile SDK status for validation limits.

Android release stack symbolication

Upload the R8 mapping from each minified release in CI. The build must enable minification to produce app/build/outputs/mapping/release/mapping.txt. Keep that file with the shipped artifact. Uploading a different mapping for an existing build key returns HTTP 409; retrying the same file is safe.

kotlin
// app/build.gradle.kts — Android Gradle Plugin 8.x, minified release variants.
androidComponents.onVariants(androidComponents.selector().withBuildType("release")) { variant ->
    val output = variant.outputs.single() // For split APKs, choose the shipped output.
    val identity = layout.buildDirectory.file("epok/" + variant.name + ".json")
    tasks.register("write" + variant.name.replaceFirstChar { it.uppercase() } + "EpokIdentity") {
        outputs.file(identity)
        // Always evaluate the final variant values, including CI overrides.
        outputs.upToDateWhen { false }
        doLast {
            val file = identity.get().asFile
            file.parentFile.mkdirs()
            file.writeText(groovy.json.JsonOutput.toJson(mapOf(
                "platform" to "android",
                "app_id" to variant.applicationId.get(),
                "version_name" to output.versionName.get(),
                "version_code" to output.versionCode.get().toString()
            )))
        }
    }
}

The Kotlin snippet exports exception spans from recordException; it does not export app.crash log rows. Open the exception stack in Traces to retrace it. Both snippets use versionName plus versionCode. For versionName-only lookup, remove app.version.code from the Kotlin resource and version_code from the Gradle identity; also omit app.build.id. Keep versionName unique per release build.

Upload is synchronous. Budget less than 100 seconds through the Cloudflare proxy: the earlier 100 MiB benchmark took 109.52 seconds including indexing, before the added PostgreSQL size-measurement pass. Large uploads can time out. Gzip reduces transfer size, not indexing time. For large maps use an operator-provided direct API origin with a longer timeout, or reduce map size; there is no universal safe byte threshold. curl --retry uses exponential backoff and honors Retry-After on 429; a timed-out identical upload is safe to retry. Do not treat a timeout as successful installation.

bash
# CI only: EPOK_SYMBOLS_KEY has symbols:write scope. Never ship it in the APK.
# EPOK_API_URL is your API origin; EPOK_TENANT_ID identifies the project account.
./gradlew :app:assembleRelease :app:writeReleaseEpokIdentity
# With flavors, use the matching assemble/identity task and mapping/<variant>/ path.
QUERY=$(python3 -c 'import json, urllib.parse; print(urllib.parse.urlencode(json.load(open("app/build/epok/release.json"))))')
gzip -c app/build/outputs/mapping/release/mapping.txt > app/build/epok/mapping.txt.gz
curl --fail-with-body --silent --show-error --retry 5 --retry-max-time 600 \
  -H "Authorization: Bearer $EPOK_SYMBOLS_KEY" \
  -H "Content-Type: text/plain; charset=utf-8" \
  -H "Content-Encoding: gzip" \
  --data-binary @app/build/epok/mapping.txt.gz \
  "$EPOK_API_URL/api/v1/tenants/$EPOK_TENANT_ID/mobile/mappings?$QUERY"
# Inspect successful responses for warnings and evicted build identities.
# HTTP 409 mapping_capacity is an error: --fail-with-body prints its JSON and
# exits nonzero. Remove an unused mapping or request more capacity, then retry.

The key is Android + service.name + service.version, optionally with one of version_code or build_id. Without an explicit build key, versionName must be unique per release build; omit version_codefrom the upload identity for that mode. Lookups are exact; a request with a build key does not fall back to a version-only mapping. The Kotlin setup below sets service.version and app.version.code from the app's BuildConfig. Ingest accepts these fields from resource, span, or log attributes, with record attributes taking precedence. For build IDs, emit app.build.id and upload the identical value. For apps using the Epok wrapper, automatic build-code metadata is planned for a later release; the wrapper does not add it automatically today.

Expand an exception stack in Traces to resolve it on read. Separately captured app.crash logs can also be retraced in RUM. Missing mappings and missing build identity remain visible, and the original stack remains available. Inline frames and ambiguous alternatives are shown; synthetic frames are retained and labelled. Unsupported R8 transformations produce warnings. This is not full compatibility with every R8 retrace extension.

Defaults: 128 MiB expanded per upload. Use gzip as shown to stay below Cloudflare's 100 MB request-body limit (compressed bodies must still fit that limit). The shared header is limited to 16 KiB; only its JSON metadata is retained, once per mapping. Each class is limited to 4 MiB; each resolve may load at most 8 MiB of selected class text. The packed class blob is capped at twice the upload limit. Busy compilers or resolve cache-miss memory budgets return HTTP 429 with Retry-After: 1; retry the same request. Per tenant/project, the index holds at most 200 builds and 1 GiB (1,073,741,824 bytes) of stored mappings. The charge is the compressed blob plus the measured PostgreSQL class-table/index footprint and retained header bytes. The measurement uses an isolated temporary table with the same row layout and indexes, excluding historical bloat and WAL. The measured 906,819-class fixture uses a 104,969,523-byte blob and a 214,925,312-byte isolated PostgreSQL footprint: 319,894,835 bytes per release. Three such releases total 959,684,505 bytes, within the default quota. Upload responses report bytes, index_bytes, and stored_bytes. Upload builds a class index; resolve fetches only the required class ranges. Capacity eviction selects the oldest eligible build, protecting uploads younger than 30 days, each app's newest three builds, and builds referenced by crash rows in the last 30 days. Failed crash-history checks preserve builds. Operators can configure EPOK_MOBILE_MAPPING_MAX_PER_TENANT, EPOK_MOBILE_MAPPING_PROTECT_DAYS, EPOK_MOBILE_MAPPING_KEEP_PER_APP, and EPOK_MOBILE_MAPPING_CRASH_PROTECT_DAYS. Evictions are listed in successful responses; HTTP 409 mapping_capacity includes limits and a recovery action.

Create a separate symbols:write key in Settings → API keys or the key creation API. Upload never accepts ingest scope alone. GET /api/v1/tenants/{id}/mobile/mappings lists builds with symbols:read scope or a session. DELETE the same path plus /{mapping_id}with an owner/admin session or a key with both read and write scopes to remove a bad mapping; the class index is removed too.

For custom tools, POST JSON to /api/v1/tenants/{id}/mobile/mappings/resolvewith the same key fields and a stack string, using symbols:read scope or a signed-in session. Version codes and build IDs are strings. Requests accept up to 16 KiB and 100 stack lines, with at most 10,000 output frames (larger expansions return HTTP 413); the response includes raw lines, candidate frame chains, ambiguity flags, warnings, and a resolved method-frame count. Existing stored crash stacks are never rewritten.

The two endpoints

Mobile SDKs configure exporters in code rather than via environment variables. Both signals go to Epok's ingest over OTLP/HTTP with your API key as a header:

bash
# Traces (spans, error events)
https://ingest.getepok.dev/v1/traces

# Metrics (counters, histograms, gauges)
https://ingest.getepok.dev/v1/metrics

# Auth — send on every request:
x-api-key: YOUR_API_KEY

# Protocol: OTLP over HTTP (http/protobuf). There is no gRPC (:4317) endpoint —
# a default gRPC exporter will silently fail.

iOS (Swift)

Add opentelemetry-swift via Swift Package Manager. One-time exporter setup, then instrument the operations you care about. The SDK's APIs shift between releases — pin a version and check its examples if a symbol has moved.

swift
// Package.swift / Xcode: add https://github.com/open-telemetry/opentelemetry-swift
// products: OpenTelemetryApi, OpenTelemetrySdk, OpenTelemetryProtocolExporterHttp

import OpenTelemetryApi
import OpenTelemetrySdk
import OpenTelemetryProtocolExporterHttp

// ── One-time setup (e.g. in AppDelegate) ────────────────────────
let headers = [("x-api-key", "YOUR_API_KEY")]
let resource = Resource(attributes: [
  "service.name": AttributeValue.string("my-ios-app"),
  "app.version": AttributeValue.string("2.4.1"),
])

let traceExporter = OtlpHttpTraceExporter(
  endpoint: URL(string: "https://ingest.getepok.dev/v1/traces")!,
  config: OtlpConfiguration(headers: headers)
)
OpenTelemetry.registerTracerProvider(tracerProvider:
  TracerProviderBuilder()
    .add(spanProcessor: BatchSpanProcessor(spanExporter: traceExporter))
    .with(resource: resource)
    .build()
)

// Metrics: see "App metrics from mobile" below — the mobile OTLP metric
// exporters emit protobuf, which Epok's direct metrics endpoint doesn't
// accept; route metrics via a small collector instead.

// ── A hand-instrumented operation ───────────────────────────────
let tracer = OpenTelemetry.instance.tracerProvider
  .get(instrumentationName: "checkout", instrumentationVersion: "1.0")

let span = tracer.spanBuilder(spanName: "checkout.submit").startSpan()
span.setAttribute(key: "cart.items", value: 3)
do {
  try submitOrder()
} catch {
  // error event → shows up as an error span in Epok
  span.addEvent(name: "exception", attributes: [
    "exception.type": AttributeValue.string(String(describing: type(of: error))),
    "exception.message": AttributeValue.string(error.localizedDescription),
  ])
  span.status = .error(description: error.localizedDescription)
}
span.end()

// ── A custom metric ─────────────────────────────────────────────
let meter = OpenTelemetry.instance.meterProvider
  .get(instrumentationName: "checkout", instrumentationVersion: "1.0")
var attempts = meter.createIntCounter(name: "checkout_attempts_total")
attempts.add(value: 1, labels: ["result": "ok"])

Optional: URLSessionInstrumentation(in the same package) auto-creates client spans for network calls and propagates trace context — that's what powers the backend correlation below.

Android (Kotlin)

Use opentelemetry-android (wraps the Java SDK with app-lifecycle awareness) plus the OTLP exporter. Same pattern: exporters once, then hand-instrumented spans, error events, and metrics. Pin versions — the RUM builder API is still evolving.

kotlin
// build.gradle.kts
// implementation("io.opentelemetry.android:android-agent:1.7.0")
// implementation("io.opentelemetry.android:core:1.7.0-alpha") // Programmatic RumBuilder API.
// implementation("io.opentelemetry:opentelemetry-exporter-otlp:1.65.0")

// ── One-time setup (Application.onCreate) ───────────────────────
// Imports:
// import io.opentelemetry.android.RumBuilder
// import io.opentelemetry.android.config.OtelRumConfig
// import io.opentelemetry.sdk.resources.Resource
// import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter
// import io.opentelemetry.api.trace.StatusCode
// import io.opentelemetry.api.common.Attributes
// import io.opentelemetry.api.common.AttributeKey
// BuildConfig is your app module's generated BuildConfig (enable buildConfig).
val releaseResource = Resource.getDefault().merge(
  Resource.builder()
    .put("service.name", BuildConfig.APPLICATION_ID)
    .put("service.version", BuildConfig.VERSION_NAME)
    .put("app.version.code", BuildConfig.VERSION_CODE.toString())
    .put("os.name", "android")
    .build()
)
val otelRum = RumBuilder.builder(this, OtelRumConfig())
  .mergeResource(releaseResource)
  .addSpanExporterCustomizer {
    OtlpHttpSpanExporter.builder()
      .setEndpoint("https://ingest.getepok.dev/v1/traces")
      .addHeader("x-api-key", "YOUR_API_KEY")
      .build()
  }
  // Metrics: see "App metrics from mobile" below — the mobile OTLP metric
  // exporters emit protobuf, which Epok's direct metrics endpoint doesn't
  // accept; route metrics via a small collector instead.
  .build()

// ── A hand-instrumented operation ───────────────────────────────
val tracer = otelRum.openTelemetry.getTracer("checkout")
val span = tracer.spanBuilder("checkout.submit").startSpan()
span.setAttribute("cart.items", 3L)
try {
  span.makeCurrent().use { submitOrder() }
} catch (e: Exception) {
  // error event → shows up as an error span in Epok
  span.recordException(e)
  span.setStatus(StatusCode.ERROR, e.message ?: "checkout failed")
  throw e
} finally {
  span.end()
}

// ── A custom metric ─────────────────────────────────────────────
val meter = otelRum.openTelemetry.getMeter("checkout")
val attempts = meter.counterBuilder("checkout_attempts_total").build()
attempts.add(1, Attributes.of(AttributeKey.stringKey("result"), "ok"))

Optional: the project's OkHttp instrumentation auto-creates client spans and injects trace context into outgoing requests.

The payoff: one trace, app to backend

When your app's HTTP client is instrumented (URLSession / OkHttp above), it injects the W3C traceparent header into every request. If your backend is instrumented for tracing, its spans carry the same trace_id— so "checkout is slow on the app" opens as a single trace: the tap in the app, the network call, and every backend span it caused, in one tree. A mobile error event and the backend exception it triggered land in the same place instead of two disconnected tools.

Verify it's working

  1. Run the app (simulator/emulator is fine) and trigger an instrumented operation.
  2. Open Traces in the app — your span appears under the service.name you set.
  3. Open Explore — hand-instrumented error spans appear alongside your backend traces (shared trace IDs correlate them automatically).

Seeing no data? The usual causes: a gRPC exporter pointed at the HTTP endpoint (use the OTLP HTTP exporter classes shown above); a missing or wrong x-api-keyheader; the batch processor not flushing before the app is killed (foreground the app for ~30s, or call the SDK's force-flush in testing); or no service.name resource attribute (data arrives but groups under unknown_service).

App metrics from mobile

The mobile OTel SDKs' HTTP metric exporters emit OTLP protobuf, and Epok's direct /v1/metrics endpoint accepts OTLP JSON only — pointing those exporters at it returns a 400. Traces are unaffected (the trace endpoint accepts protobuf, which is why the snippets above work as shown).

To ship app metrics, run a small OpenTelemetry Collector your apps export to, with its otlphttp exporter set to encoding: json and pointed at https://ingest.getepok.dev — or start with traces and errors only; they cover most mobile debugging and correlate with your backend out of the box.