Skip to main content

TracerProvider Integration Guide

Overview

TracerProvider is the SPI trait for distributed tracing in Constellation Engine. Implement this trait to wrap DAG and module executions with trace spans, enabling end-to-end visibility in systems like Jaeger, Zipkin, or any OpenTelemetry-compatible backend.

Trait API

package io.constellation.spi

import cats.effect.IO

trait TracerProvider {
/** Wrap an IO computation with a trace span. */
def span[A](name: String, attributes: Map[String, String])(body: IO[A]): IO[A]
}

object TracerProvider {
/** No-op implementation (default). Passes body through unchanged. */
val noop: TracerProvider = new TracerProvider {
def span[A](name: String, attributes: Map[String, String])(body: IO[A]): IO[A] = body
}
}

The runtime wraps key operations with spans:

Span NameAttributesWraps
execution.rundag_name, execution_idEntire DAG execution
module.executemodule_name, module_idIndividual module execution

Example 1: OpenTelemetry via otel4s

Dependencies:

libraryDependencies ++= Seq(
"org.typelevel" %% "otel4s-oteljava" % "0.4.0",
"io.opentelemetry" % "opentelemetry-exporter-otlp" % "1.34.0",
"io.opentelemetry" % "opentelemetry-sdk-extension-autoconfigure" % "1.34.0"
)

Implementation:

import io.constellation.spi.TracerProvider
import cats.effect.IO
import org.typelevel.otel4s.trace.Tracer

class OtelTracerProvider(tracer: Tracer[IO]) extends TracerProvider {

def span[A](name: String, attributes: Map[String, String])(body: IO[A]): IO[A] = {
val spanBuilder = tracer.spanBuilder(name)
val withAttrs = attributes.foldLeft(spanBuilder) { case (b, (k, v)) =>
b.addAttribute(org.typelevel.otel4s.Attribute(k, v))
}
withAttrs.build.use { span =>
body.handleErrorWith { err =>
span.recordException(err) *>
span.setStatus(org.typelevel.otel4s.trace.StatusCode.Error) *>
IO.raiseError(err)
}
}
}
}

Wiring:

import org.typelevel.otel4s.oteljava.OtelJava

OtelJava.autoConfigured[IO]().use { otel =>
otel.tracerProvider.get("constellation").flatMap { tracer =>
val tracerProvider = new OtelTracerProvider(tracer)

val constellation = ConstellationImpl.builder()
.withTracer(tracerProvider)
.build()

// ... run application
}
}

Environment configuration for OTLP exporter:

OTEL_SERVICE_NAME=constellation-engine
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317

Example 2: Jaeger (Direct)

Dependencies:

libraryDependencies ++= Seq(
"io.opentelemetry" % "opentelemetry-sdk" % "1.34.0",
"io.opentelemetry" % "opentelemetry-exporter-jaeger" % "1.34.0"
)

Implementation:

import io.constellation.spi.TracerProvider
import io.opentelemetry.api.trace.{Tracer => JTracer, Span, StatusCode}
import cats.effect.IO

class JaegerTracerProvider(tracer: JTracer) extends TracerProvider {

def span[A](name: String, attributes: Map[String, String])(body: IO[A]): IO[A] = {
IO.bracket(
IO {
val spanBuilder = tracer.spanBuilder(name)
attributes.foreach { case (k, v) =>
spanBuilder.setAttribute(k, v)
}
spanBuilder.startSpan()
}
)(span =>
IO.defer {
val scope = span.makeCurrent()
body.attempt.flatMap {
case Right(a) =>
IO { scope.close(); a }
case Left(err) =>
IO {
span.setStatus(StatusCode.ERROR, err.getMessage)
span.recordException(err)
scope.close()
} *> IO.raiseError(err)
}
}
)(span => IO(span.end()))
}
}

Wiring:

import io.opentelemetry.sdk.OpenTelemetrySdk
import io.opentelemetry.sdk.trace.SdkTracerProvider
import io.opentelemetry.exporter.jaeger.JaegerGrpcSpanExporter

val exporter = JaegerGrpcSpanExporter.builder()
.setEndpoint("http://localhost:14250")
.build()

val sdkTracerProvider = SdkTracerProvider.builder()
.addSpanProcessor(io.opentelemetry.sdk.trace.export.SimpleSpanProcessor.create(exporter))
.build()

val otelSdk = OpenTelemetrySdk.builder()
.setTracerProvider(sdkTracerProvider)
.build()

val tracer = otelSdk.getTracer("constellation-engine")
val tracerProvider = new JaegerTracerProvider(tracer)

val constellation = ConstellationImpl.builder()
.withTracer(tracerProvider)
.build()

Gotchas

  • Context propagation: The span method wraps an IO[A] computation. The span is active for the duration of body. Nested spans (e.g., module spans inside execution spans) inherit the parent context automatically if your tracer implementation supports it.
  • Error recording: Record exceptions on the span before re-raising. The examples above demonstrate this pattern.
  • Performance: Span creation and attribute setting should be fast. Avoid expensive serialization in attributes.
  • No-op default: When TracerProvider.noop is used (the default), the body computation passes through with zero overhead — no span objects are created.
  • Concurrency: Modules execute in parallel. Each module span is independent. The execution span is the parent of all module spans within that DAG run.