Skip to content

Add spanUnsafe for manual span lifecycle management (ZQuery / non-callback contexts) #1154

Description

@li-nkSN

Problem

Tracer.span uses a callback pattern that manages context via ZIO.acquireReleaseWith internally. This works well for standard ZIO effects, but breaks down when integrating with libraries that have their own resource lifecycle — most notably Caliban's ZQuery.

Caliban's FieldWrapper operates on ZQuery, which provides ZQuery.acquireReleaseWith(acquire)(release)(use) with separate acquire/release effects. To create field-level spans that properly nest repository-level spans underneath them, you need to:

  1. Start a span and push its context (acquire)
  2. Run the wrapped query, which may internally call Tracer.span for repo-level work (use)
  3. End the span and restore the parent context (release)

The callback pattern cannot express this because ZQuery.acquireReleaseWith requires acquire: ZIO[R, E, A] and release: A => ZIO[R, Nothing, Any] as separate values — there is no way to thread a callback-scoped span through them.

Without this, field-level spans in a Caliban schema tracer cannot act as parents for the spans created by downstream resolvers, resulting in a flat span hierarchy instead of the expected field → repository nesting.

Relationship to #1140 (LogSpanner.setAttribute)

spanUnsafe and setAttribute (#1140) are complementary pieces of the same Caliban tracing story:

In a typical Caliban GraphQL stack, the flow is:

FieldWrapper (spanUnsafe creates the field span)
  └─ DataSource batch resolver (setAttribute enriches with batch.size, batch.request_count)
       └─ Repository (LogSpanner.span creates the repo span)

setAttribute dispatches through the same FiberRef mechanism as LogSpanner.span, so it writes to the innermost span — which is the field span created by spanUnsafe when called from a DataSource, or the repo span when called from deeper code.

Proposed API

def spanUnsafe(
  spanName: String,
  spanKind: SpanKind = SpanKind.INTERNAL,
  attributes: Attributes = Attributes.empty(),
  links: Seq[SpanContext] = Seq.empty
)(implicit trace: Trace): ZIO[Any, Nothing, (Span, UIO[Unit])]

Returns (span, endEffect) where:

  • span is the started child span with its context already pushed to ContextStorage
  • endEffect ends the span and restores the previous context

This also requires a ContextStorage.set(ctx: Context): UIO[Unit] method to push context without callback scoping.

This is the ZIO equivalent of OpenTelemetry Java SDK's Span.makeCurrent(), which returns a Scope for manual lifecycle management.

Example: Caliban FieldWrapper

class FieldTracer(tracer: Tracer) extends FieldWrapper[Any](wrapPureValues = false) {
  def wrap[R](
    query: ZQuery[R, CalibanError.ExecutionError, ResponseValue],
    info: FieldInfo
  ): ZQuery[R, CalibanError.ExecutionError, ResponseValue] =
    ZQuery.acquireReleaseWith(tracer.spanUnsafe(info.name)) {
      case (_, end) => end
    } { case (span, _) =>
      query.foldCauseQuery(
        cause => { span.setStatus(StatusCode.ERROR, cause.prettyPrint); ZQuery.failCause(cause) },
        value => ZQuery.succeed(value)
      )
    }
}

With this, any Tracer.span or LogSpanner.span calls inside the query's resolvers will correctly appear as children of the field span, and LogSpanner.setAttribute calls will enrich the correct span in the hierarchy.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions