Distributed traces from the tracer your app already runs, rendered as a waterfall. Alohomora depends on no tracing SDK.
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.
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" )
For timing one block with no surrounding trace context, use the short form. It renders as a single-span trace.
val start = System.nanoTime() decodeImage(bitmap) Alohomora.recordSpan(name = "image_decode", durationNanos = System.nanoTime() - start)
Timestamps are epoch nanoseconds, and converting is the adapter's job.
Double. Multiply by 1e9 and cast to Long.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.
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.
debugImplementation vs releaseImplementation), not the source set level. Your adapter code is identical in both configurations.
Both are String, not enums. They carry your tracer's vocabulary as-is.
CLIENT, SERVER, INTERNAL, PRODUCER, CONSUMER) to distinct colours. Unknown kinds get the INTERNAL colour."ERROR" for statusCode to get failure styling (red bar, error badge).Implement SpanExporter. Each call to export() receives a batch of finished spans. Forward each one to recordSpan() and return ofSuccess() immediately.
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() }
ofSuccess() immediately. recordSpan is fire-and-forget. Returning an uncompleted CompletableResultCode stalls BatchSpanProcessor for 30 seconds per batch.
Add the exporter as one processor among your existing ones in SdkTracerProvider:
val tracerProvider = SdkTracerProvider.builder() .addSpanProcessor(BatchSpanProcessor.builder(OtlpGrpcSpanExporter.getDefault()).build()) .addSpanProcessor(SimpleSpanProcessor.create(AlohomoraSpanExporter())) .build()
Hook into beforeSendTransaction, which sees each finished transaction and its child spans before they leave the device.
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 } }
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.Double — the * 1e9 conversion to nanos is required.Neither exposes a span-export hook. Call recordSpan at the trace sites directly.
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) }
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.