Late span reparenting - #160
Conversation
Replace the bare IORef TracingSpan behind each LogHandler with an Open -> Closing -> Finished lifecycle (SpanTarget). Parent finalization and child attachment now linearize through atomic state transitions: beginFinish closes the attachment window before the final timestamp is read, tryAttach only succeeds while the span is Open, and repeated finalization is a no-op instead of attaching the span twice. Mutations (details, summary, failure marking) only apply while the span is Open, matching their effective reporting behavior before this change. A child completing after its parent finished is still dropped at this point, as before; reparenting builds on this lifecycle next. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A LogHandler can escape its span's dynamic lifetime (threads joined outside the span, deferred callbacks, lazily-consumed streaming responses). Children created or finished through such a handler after the parent span finished used to disappear silently. Introduce TraceRoot: every handler knows the root of the trace it belongs to. When a completed child cannot attach to its intended parent because the parent already finished, it now attaches directly to the still-open trace root instead. Platform.newRoot allocates a fresh TraceRoot, so late children inside it reparent to that new root rather than the surrounding trace. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When a late span cannot attach to its intended parent nor to the trace root because the whole trace has already been reported, send it through the root reporter as a separate root span instead of dropping it. This matches the conceptual behavior of Platform.newRoot: preserving the span beats silently discarding it, even without the original request context. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every operation (tryAttach, beginFinish, modifyOpenSpan) treated the two states identically, so the distinction bought nothing observable: the attachment window closes at the Open -> Closed transition, and the draft span lives on the finalizing caller's stack from that point on. Dropping the second transition removes markFinished and a comment that misstated why it existed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens Platform tracing so spans that outlive their intended parent are no longer silently dropped. It introduces an explicit “open vs closed” span lifecycle to make parent finalization and child attachment atomic, enabling late spans to be reparented to the trace root (or emitted as a new root when the trace is already closed), and adds tests to cover these scenarios.
Changes:
- Introduce
SpanTarget/SpanStateandTraceRootto make span completion/attachment races atomic and make repeated finalization idempotent. - Reparent “late” child spans to the still-open trace root; if the root is closed, report the child as a separate root span.
- Add a comprehensive test suite for late span reparenting behavior and concurrency races; document the fix in the changelog.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
nri-prelude/src/Platform/Internal.hs |
Adds atomic span state management and late-span reparenting/reporting behavior; refactors handler construction accordingly. |
nri-prelude/tests/PlatformSpec.hs |
Adds targeted tests covering late span reparenting, idempotent finalization, and concurrent parent/child finalization races. |
nri-prelude/CHANGELOG.md |
Documents the behavioral fix and clarifies that the public API and serialized TracingSpan format are unchanged. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
micahhahn
left a comment
There was a problem hiding this comment.
Fascinating.
I hadn't really investigated the span trace code until ruminating on this PR.
One question I have... is onFinishRoot the point where we actually serialize the spans (to log-explorer, honeycomb, etc)? I think in this case where a child has not finalized the changes in this PR will still result in the child being silently dropped.
Attaching a child to the parent in the child's finish handler seems wrong to me (in the way the code works before this PR). It seems better to me if the parent adds the child to its list of children at the moment startChildTracingSpan so it's aware of all its children.
Could we modify TracingSpan to look like this?
data TracingSpan f = TracingSpan
{
...
children :: [f TracingSpan]
}We would have TracingSpan IORef as the type for the duration while we are still collecting spans and then finally when asked to finalize the root we could walk down the tree resolving the IORef TracingSpans as go and converting the overall type to TracingSpan Identity (isomorphic to how it currently is). This would allows us to mark incomplete children as incomplete which could be very useful!
|
Attaching to a parent after
Adding children at start doesn't sound like a great solution to me, because Letting the child choose what to do when its parent is closed sounds better, so we can reparent or promote to root, like we're doing here. It's also a much smaller surface-area changeset :S |
|
@micahhahn friendly nudge in case you missed the answer above (not sure if you get notified w/o @'ing) |
micahhahn
left a comment
There was a problem hiding this comment.
Tricky stuff!
This is a big improvement over silently dropping spans, thanks for the work on this. LGTM!
|
Recording here a suggestion from Micah on Slack which I really liked: Promoting a span to root non-deterministically is not great. We could do both:
Ideally, the unfinished child has a clear link to the promoted root (referencing the new root's trace_id?) |
A child span can end after its parent span has ended. This can happen in a few scenarios:
In these cases, the child was getting lost, because it was being attached to a parent that had already been copied out of its
IORefand into its parent.The solution we found is:
Alternatively, we could have added support for attaching children to finished parents, by keeping
IORefs around until the root is processed. That was a much more invasive change, and for the use-case we're handling while we hit this, reparenting to root looks good, so I decided to go with that.ps: i have tested integrating this into the NoRedInk monorepo pre-merge and have confirmed the fix