| name | custom-metrics |
| description | Emit counters, gauges, and histograms from mobile apps using OpenTelemetry metrics, with patterns for batching, labels, and avoiding cardinality explosions. Use when adding product or engineering metrics beyond the core RUM set. |
Custom Metrics on Mobile
Instructions
When the core RUM SLIs are not enough, emit custom metrics. Keep them few, bounded, and named consistently.
1. Choose the Instrument Type
- Counter: monotonic increments. Good for "X happened". Example:
checkout.attempt.count.
- UpDownCounter: increments and decrements. Good for "how many X are in flight". Example:
media.player.active.
- Gauge (observable): instantaneous value observed on a schedule. Good for "current battery level", "pending queue size".
- Histogram: distribution of values. Good for durations, sizes, counts-per-event. Example:
image.decode.duration_ms.
Do not use a counter where a histogram is appropriate; you lose percentiles forever.
2. Naming Convention
<domain>.<subject>.<measure>[.<unit>], all snake_case.
checkout.payment.submit.count
checkout.payment.submit.duration_ms (histogram)
feed.scroll.jank_frame.count
media.decode.bytes (histogram of bytes)
Include the unit in the name when it's not obvious. The unit also goes in setUnit(...) on the instrument for vendor dashboards.
3. Android (Kotlin, OTel)
object Metrics {
private val meter = GlobalOpenTelemetry.getMeter("my-app")
val checkoutSubmits: LongCounter = meter.counterBuilder("checkout.payment.submit.count")
.setDescription("Number of payment submit attempts")
.build()
val checkoutDuration: DoubleHistogram = meter.histogramBuilder("checkout.payment.submit.duration_ms")
.setUnit("ms")
.setExplicitBucketBoundariesAdvice(listOf(50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0))
.build()
}
fun submitPayment(method: String) {
val start = System.nanoTime()
val result = runCatching { paymentApi.submit() }
val duration = (System.nanoTime() - start) / 1_000_000.0
val attrs = Attributes.of(
AttributeKey.stringKey("method"), method,
AttributeKey.stringKey("outcome"), if (result.isSuccess) "ok" else "error"
)
Metrics.checkoutSubmits.add(1, attrs)
Metrics.checkoutDuration.record(duration, attrs)
}
4. iOS (Swift, OTel)
let meter = OpenTelemetry.instance.meterProvider.get(instrumentationName: "my-app")
let submits = meter.createIntCounter(name: "checkout.payment.submit.count")
let duration = meter.createDoubleHistogram(name: "checkout.payment.submit.duration_ms",
unit: "ms")
func submitPayment(method: String) async {
let start = DispatchTime.now()
let outcome: String
do { try await paymentApi.submit(); outcome = "ok" }
catch { outcome = "error" }
let ms = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000
let labels: [String: AttributeValue] = [
"method": .string(method),
"outcome": .string(outcome)
]
submits.add(value: 1, labels: labels)
duration.record(value: ms, labels: labels)
}
5. Flutter (Dart)
Dart OTel is still maturing; the pragmatic path is to emit metrics via Sentry (Sentry.metrics()) or Firebase Performance custom metrics, and convert to OTel at the collector.
Sentry.metrics().increment('checkout.payment.submit.count',
value: 1, tags: {'method': method, 'outcome': outcome});
Sentry.metrics().distribution('checkout.payment.submit.duration_ms',
value: durationMs, unit: DurationSentryMeasurementUnit.milliSecond,
tags: {'method': method, 'outcome': outcome});
6. React Native (TypeScript)
import { metrics } from '@opentelemetry/api';
const meter = metrics.getMeter('my-app');
const submits = meter.createCounter('checkout.payment.submit.count');
const duration = meter.createHistogram('checkout.payment.submit.duration_ms', {
unit: 'ms',
advice: { explicitBucketBoundaries: [50, 100, 250, 500, 1000, 2500, 5000] },
});
export async function submitPayment(method: string) {
const start = performance.now();
let outcome = 'ok';
try { await paymentApi.submit(); } catch { outcome = 'error'; throw; }
finally {
const attrs = { method, outcome };
submits.add(1, attrs);
duration.record(performance.now() - start, attrs);
}
}
7. Emission Patterns
- Emit once per business event, not once per UI tick.
- Always record outcome: success and failure counts must be the same instrument with an
outcome label; otherwise dashboards cannot compute error rate.
- Pre-allocate instruments at module load; do not create a new histogram per call.
- Avoid side effects: emitting a metric must never throw or block. Wrap in a try/catch at the call site if the SDK is not guaranteed safe.
8. Label Budget
Hard cap: 4 labels per metric. If you need more dimensions, emit multiple separate metrics.
Typical labels:
outcome (ok, error, timeout)
method / action (bounded enum)
screen (from the registry)
error_class (only when outcome != ok)
Forbidden as labels: user_id, session_id, trace_id, raw URLs, free-text input.
9. Aggregation Strategy
- Delta temporality for mobile: you cannot rely on a long-lived accumulator because processes die often. Configure the exporter with
AggregationTemporality.DELTA.
- PeriodicMetricReader flush interval: 60 s in foreground, disabled in background (drain on
onPause / applicationWillResignActive).
val reader = PeriodicMetricReader.builder(otlpMetricExporter)
.setInterval(Duration.ofSeconds(60))
.build()
10. Dashboards
- A per-feature dashboard with
count, error_rate = error_count / total_count, and the histogram's p50/p95.
- Always include a "volume" panel so you can distinguish "SLI improved" from "traffic dropped".
Checklist