Overview

Alohomora renders distributed traces as a waterfall: spans grouped by trace ID, nested by parent, showing name, kind, duration, status, attributes, and events.

The entire ingestion surface is one function: recordSpan(). Write a ~15-line adapter from whatever tracer your app already runs. Ready-made adapters for OpenTelemetry and Sentry are below.

This is deliberate. Every tracer's export hook is vendor-specific (OTel SpanExporter, Sentry beforeSendTransaction, Datadog's own tracer, Firebase Performance with no export hook at all), so a library-side adapter would drag an SDK dependency into both published modules and serve only one vendor.

recordSpan API

Full form

Kotlin
Alohomora.recordSpan(
    traceId = "0af7651916cd43dd8448eb211c80319c",        // 32 hex chars
    spanId = "b7ad6b7169203331",                        // 16 hex chars
    parentSpanId = "00f067aa0ba902b7",                   // null or 16 zeros for root
    name = "GET /api/users",
    startEpochNanos = 1700000000000000000L,
    endEpochNanos = 1700000000050000000L,
    kind = "CLIENT",                                    // String, not an enum
    statusCode = "OK",                                  // String, not an enum
    statusDescription = null,
    attributes = mapOf("http.method" to "GET"),
    events = listOf(
        SpanEvent(
            name = "exception",
            epochNanos = 1700000000040000000L,
            attributes = mapOf("exception.message" to "timeout")
        )
    ),
    scopeName = "io.example.http"
)

Short form

For timing one block with no surrounding trace context, use the short form. It renders as a single-span trace.

Kotlin
val start = System.nanoTime()
decodeImage(bitmap)
Alohomora.recordSpan(name = "image_decode", durationNanos = System.nanoTime() - start)

Timestamp Rules

Timestamps are epoch nanoseconds, and converting is the adapter's job.

  • OpenTelemetry — already nanos. Pass through.
  • Sentry — fractional seconds as Double. Multiply by 1e9 and cast to Long.
  • Milliseconds — multiply by 1_000_000.

Why nanos: sub-millisecond resolution is needed for the waterfall. At millisecond precision, five sequential 200 us spans would appear simultaneous — a wrong picture, not a coarse one.

Get the conversion wrong and every span renders as a 1970 date. If your spans cluster around January 1970 in the waterfall, you are passing seconds or milliseconds instead of nanoseconds.

Source Set Guidance

Put the adapter in src/main, not a debug-only source set.

alohomora-noop mirrors recordSpan as a no-op, so the adapter compiles in release and discards every span at the call site. R8 removes the call along with argument construction.

No conditional imports needed. The debug/release split is handled at the dependency level (debugImplementation vs releaseImplementation), not the source set level. Your adapter code is identical in both configurations.

kind and statusCode

Both are String, not enums. They carry your tracer's vocabulary as-is.

  • Unrecognized values are stored and displayed verbatim, never rejected.
  • The waterfall maps known kinds (CLIENT, SERVER, INTERNAL, PRODUCER, CONSUMER) to distinct colours. Unknown kinds get the INTERNAL colour.
  • Use "ERROR" for statusCode to get failure styling (red bar, error badge).

OpenTelemetry Adapter

Implement SpanExporter. Each call to export() receives a batch of finished spans. Forward each one to recordSpan() and return ofSuccess() immediately.

Kotlin
class AlohomoraSpanExporter : SpanExporter {
    override fun export(spans: Collection<SpanData>): CompletableResultCode {
        spans.forEach { span ->
            Alohomora.recordSpan(
                traceId = span.traceId,
                spanId = span.spanId,
                name = span.name,
                startEpochNanos = span.startEpochNanos,
                endEpochNanos = span.endEpochNanos,
                parentSpanId = span.parentSpanId,
                kind = span.kind.name,
                statusCode = span.status.statusCode.name,
                statusDescription = span.status.description,
                attributes = span.attributes.toStringMap(),
                events = span.events.map { event ->
                    SpanEvent(
                        name = event.name,
                        epochNanos = event.epochNanos,
                        attributes = event.attributes.toStringMap(),
                    )
                },
                scopeName = span.instrumentationScopeInfo.name,
            )
        }
        return CompletableResultCode.ofSuccess()
    }
    override fun flush(): CompletableResultCode = CompletableResultCode.ofSuccess()
    override fun shutdown(): CompletableResultCode = CompletableResultCode.ofSuccess()
}

private fun Attributes.toStringMap(): Map<String, String> =
    asMap().entries.associate { (key, value) -> key.key to value.toString() }
Return ofSuccess() immediately. recordSpan is fire-and-forget. Returning an uncompleted CompletableResultCode stalls BatchSpanProcessor for 30 seconds per batch.

Registration

Add the exporter as one processor among your existing ones in SdkTracerProvider:

Kotlin
val tracerProvider = SdkTracerProvider.builder()
    .addSpanProcessor(BatchSpanProcessor.builder(OtlpGrpcSpanExporter.getDefault()).build())
    .addSpanProcessor(SimpleSpanProcessor.create(AlohomoraSpanExporter()))
    .build()
Declare the OTel dependency in your module, not Alohomora's. Alohomora has no dependency on the OpenTelemetry SDK and must not gain one. The adapter lives in your app code.

Sentry Adapter

Hook into beforeSendTransaction, which sees each finished transaction and its child spans before they leave the device.

Kotlin
SentryAndroid.init(context) { options ->
    options.setBeforeSendTransaction { txn, _ ->
        txn.spans.forEach { span ->
            Alohomora.recordSpan(
                traceId = span.traceId.toString(),
                spanId = span.spanId.toString(),
                parentSpanId = span.parentSpanId?.toString(),
                name = span.description ?: span.op,
                startEpochNanos = (span.startTimestamp * 1e9).toLong(),
                endEpochNanos = span.timestamp?.let { (it * 1e9).toLong() }
                    ?: (span.startTimestamp * 1e9).toLong(),
                kind = span.op,
                statusCode = when (span.status) {
                    null -> "UNSET"
                    SpanStatus.OK -> "OK"
                    else -> "ERROR"
                },
                statusDescription = span.status?.apiName(),
                attributes = span.data?.mapValues { it.value.toString() } ?: span.tags,
                scopeName = span.origin,
            )
        }
        txn
    }
}
  • The root span lives on txn.contexts.trace, not in txn.spans. To include it, extract traceId, spanId, op, startTimestamp, timestamp, and status from the transaction's trace context and call recordSpan for it as well.
  • txn.spans returns the live backing list. Do not mutate it.
  • Sentry timestamps are fractional seconds as Double — the * 1e9 conversion to nanos is required.

Datadog & Firebase Performance

Neither exposes a span-export hook. Call recordSpan at the trace sites directly.

Kotlin — Firebase Performance
val trace = FirebasePerformance.getInstance().newTrace("checkout")
trace.start()
val startNanos = System.nanoTime()
try {
    checkout()
} finally {
    trace.stop()
    Alohomora.recordSpan(name = "checkout", durationNanos = System.nanoTime() - startNanos)
}
Use System.nanoTime(), not System.currentTimeMillis(). nanoTime is a monotonic clock suitable for measuring elapsed time. currentTimeMillis is a wall clock that can jump on NTP adjustments, producing negative or wildly inflated durations.