Blue-Falcon

Kotlin Multiplatform BLE library for iOS, Android, macos, windows and javascript

View the Project on GitHub Reedyuk/blue-falcon

ADR 0012: Metrics/Observability Plugin

Status: ✅ Implemented (blue-falcon-plugin-metrics core module; the optional blue-falcon-plugin-metrics-otel OpenTelemetry exporter is tracked separately and not yet built)

Date: 2026-08-27

Deciders: Blue Falcon maintainers and community contributors

Technical Story: Diagnosing BLE reliability issues in production (connection success rates, operation latency, throughput) currently requires each consuming app to hand-instrument calls into BlueFalcon, with no shared, reusable way to capture or export these metrics.

Context

PluginRegistry already wraps every scan/connect/read/write/disconnect call through onBeforeX/onAfterX interception hooks (used today by LoggingPlugin, CachingPlugin, RetryPlugin, etc. — see ADR 0002). These hooks see every operation’s start, its Result outcome, and (via RetryCapable, added when the retry plugin was fixed) how many attempts it took. This is exactly the instrumentation point a metrics plugin needs: it can observe every operation without any engine changes, in exactly the same non-invasive way every other plugin in this codebase already works.

Today there is no metrics or observability plugin, and no OpenTelemetry or Micrometer dependency anywhere in the codebase. Apps that want to track “what’s my connection success rate in the field” or “how long do reads/writes actually take on real devices” have to build this themselves on top of raw BlueFalconDelegate callbacks or by wrapping BlueFalcon calls manually.

Decision

We will add a blue-falcon-plugin-metrics module that hooks into PluginRegistry’s existing interception points to record connection success/failure counts, per-operation latency histograms, and read/write throughput, exposed through a small exporter abstraction so the core metrics plugin does not force an OpenTelemetry (or any other specific telemetry SDK) dependency onto every consumer. A separate, optional companion module (blue-falcon-plugin-metrics-otel) provides an OpenTelemetry-backed exporter for apps that want it.

New types (plugins/metrics)

data class OperationMetric(
    val operation: MetricOperation,     // Scan, Connect, Disconnect, Read, Write
    val peripheralUuid: String?,        // null for Scan, which is not peripheral-scoped
    val success: Boolean,
    val durationMillis: Long,
    val attemptCount: Int,              // 1 if no retry plugin involved, >1 if RetryCapable retried
    val byteCount: Int?,                // populated for Read/Write, for throughput calculation
)

interface MetricsExporter {
    fun record(metric: OperationMetric)
}

class MetricsPlugin(private val config: Config) : BlueFalconPlugin {
    class Config : PluginConfig() {
        var exporters: List<MetricsExporter> = emptyList()
    }

    /** Always-on in-process aggregation, independent of any configured exporter — cheap default visibility. */
    val snapshot: StateFlow<MetricsSnapshot>
}

data class MetricsSnapshot(
    val connectSuccessCount: Long,
    val connectFailureCount: Long,
    val connectLatencyHistogram: Histogram,
    val readLatencyHistogram: Histogram,
    val writeLatencyHistogram: Histogram,
    val bytesRead: Long,
    val bytesWritten: Long,
)

/** Minimal, dependency-free histogram — bucket boundaries configurable, default tuned for BLE latencies (ms). */
class Histogram(boundariesMillis: List<Long> = defaultBleLatencyBuckets) {
    fun record(valueMillis: Long)
    fun snapshotCounts(): Map<Long, Long>   // bucket upper-bound -> count
}

MetricsPlugin implements onAfterConnect/onAfterRead/onAfterWrite/onAfterScan (already present in BlueFalconPlugin), measuring elapsed time itself around onBeforeX/onAfterX pairs via a per-call-id timestamp map, and building one OperationMetric per completed operation. Every recorded metric both updates the always-on in-process snapshot StateFlow (cheap, allocation- light aggregation with no external dependency) and is forwarded to every configured MetricsExporter.

Exporter abstraction, not a hard OTel dependency

blue-falcon-plugin-metrics depends only on blue-falcon-core and kotlinx-coroutines — nothing telemetry-specific. MetricsExporter is a one-method interface; anyone can implement it (log to console, push to a custom backend, bridge to Firebase/Crashlytics, etc.) without pulling in any particular SDK.

For teams that specifically want OpenTelemetry, a separate opt-in module blue-falcon-plugin-metrics-otel will provide:

class OpenTelemetryMetricsExporter(meter: Meter) : MetricsExporter {
    // maps OperationMetric -> OTel Counter/Histogram instruments (connect.success, connect.duration, read.bytes, ...)
}

This keeps the OpenTelemetry SDK dependency confined to the one artifact that needs it, consistent with how blue-falcon-plugin-nordic-fota already isolates its Nordic-DFU-specific dependency away from blue-falcon-core and every other plugin.

Consequences

Positive

Negative

Neutral

Alternatives Considered

Alternative 1: Depend on OpenTelemetry directly in the main metrics plugin

Skip the exporter abstraction and have blue-falcon-plugin-metrics itself depend on the OTel SDK.

Pros:

Cons:

Why not chosen: The exporter interface costs one small abstraction and one extra module, and in exchange keeps the core metrics plugin vendor-neutral and lightweight — directly matching the user’s ask for metrics that are merely “exportable to OpenTelemetry,” not OpenTelemetry-only.

Alternative 2: Build metrics collection into core rather than as a plugin

Add success/failure counters and latency tracking directly to BlueFalcon/PluginRegistry.

Pros:

Cons:

Why not chosen: Plugin-based instrumentation is strictly better here: opt-in, zero core changes, and it can already see everything it needs through existing hooks.

Implementation Notes

References