inferCausalLinks

Derive SpanLinks from the happens-before relation recorded in each span's SpanRecord.causalStamp.

Unstamped spans are ignored. For each stamped successor e2, each predecessor dot is resolved to a span e1; an edge e2 → e1 is emitted iff e1.spanId != e2.parentSpanId — the "cross-boundary" filter. That single condition drops predecessor edges that merely duplicate the explicit parent (ordinary intra-trace tree structure) and keeps the novel edges that hand-rolled context propagation would have lost: cross-trace, or same-trace but not the parent.

A predecessor dot that doesn't resolve (late or partial sync) is skipped and logged at debug — never thrown. The result is sorted by (fromSpanId, linkedSpanId) for deterministic, input-order-independent output.

Samples

val clock = WarpCausalClock(ReplicaId("device-uuid-abc123"))

// One tick per span, in creation order — the clock chains the happens-before frontier.
val checkout = clock.tick()   // root span of one trace
val charge = clock.tick()     // caused by checkout, but in a *different* trace

fun span(id: Byte, trace: Byte, parent: Byte?, stamp: CausalStamp) = SpanRecord(
    traceId = ByteString(ByteArray(16) { trace }),
    spanId = ByteString(ByteArray(8) { id }),
    parentSpanId = parent?.let { p -> ByteString(ByteArray(8) { p }) },
    name = "span-$id",
    kind = SpanKind.INTERNAL,
    startEpochNanos = 1_000_000_000L,
    endEpochNanos = 2_000_000_000L,
    causalStamp = stamp,
)

val checkoutSpan = span(id = 1, trace = 1, parent = null, stamp = checkout)
val chargeSpan = span(id = 2, trace = 2, parent = null, stamp = charge)

// The cross-trace edge explicit context propagation would have lost.
val links = inferCausalLinks(listOf(checkoutSpan, chargeSpan))
check(links.size == 1)
check(links.single().attributes["kuilt.causality"] == "potential")