From 66758e0809ec8ce9675c776337662ee7b0cf1066 Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Fri, 1 May 2026 21:33:48 +0200 Subject: [PATCH 01/17] Add ZIO OpenTelemetry tracing support with new providers and configuration --- build.sbt | 29 ++- .../observability/ZIOpenTelemetryExample.skip | 173 ++++++++++++++ .../server/o11y/otel4z/ZIOpenTelemetry.scala | 16 ++ .../server/o11y/otel4z/ZIOpenTelemetry.scala | 17 ++ .../server/o11y/otel4z/LoggerProvider.scala | 50 +++++ .../server/o11y/otel4z/MeterProvider.scala | 80 +++++++ .../server/o11y/otel4z/OtlpEndpoint.scala | 25 +++ .../tapir/server/o11y/otel4z/Providers.scala | 79 +++++++ .../server/o11y/otel4z/TracerProvider.scala | 94 ++++++++ .../o11y/otel4z/ZIOpenTelemetryBase.scala | 19 ++ .../tapir/server/o11y/otel4z/ZIOtelBase.scala | 84 +++++++ .../server/o11y/otel4z/ZIOtelLayer.scala | 70 ++++++ .../server/o11y/otel4z/ZIOtelTracing.scala | 211 ++++++++++++++++++ .../o11y/otel4z/ZIOtelTracingConfig.scala | 142 ++++++++++++ .../o11y/otel4z/ZIOtelTracingTestApp.scala | 8 + .../o11y/otel4z/ZIOtelTracingTestApp.scala | 8 + .../o11y/otel4z/ZIOtelTracingTest.scala | 96 ++++++++ project/Versions.scala | 5 +- 18 files changed, 1204 insertions(+), 2 deletions(-) create mode 100644 examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip create mode 100644 observability/otel4z/src/main/scala-2/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala create mode 100644 observability/otel4z/src/main/scala-3/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala create mode 100644 observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/LoggerProvider.scala create mode 100644 observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/MeterProvider.scala create mode 100644 observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/OtlpEndpoint.scala create mode 100644 observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/Providers.scala create mode 100644 observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/TracerProvider.scala create mode 100644 observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryBase.scala create mode 100644 observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelBase.scala create mode 100644 observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelLayer.scala create mode 100644 observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracing.scala create mode 100644 observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingConfig.scala create mode 100644 observability/otel4z/src/test/scala-2/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTestApp.scala create mode 100644 observability/otel4z/src/test/scala-3/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTestApp.scala create mode 100644 observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala diff --git a/build.sbt b/build.sbt index 506effa9ad..478dfdd8ee 100644 --- a/build.sbt +++ b/build.sbt @@ -1,3 +1,4 @@ +import Versions.zioHttp import com.softwaremill.Publish.{ossPublishSettings, updateDocs} import com.softwaremill.SbtSoftwareMillBrowserTestJS._ import com.softwaremill.SbtSoftwareMillCommon.commonSmlBuildSettings @@ -192,6 +193,7 @@ lazy val rawAllAggregates = core.projectRefs ++ opentelemetryTracing.projectRefs ++ otel4sMetrics.projectRefs ++ otel4sTracing.projectRefs ++ + otel4z.projectRefs ++ json4s.projectRefs ++ playJson.projectRefs ++ play29Json.projectRefs ++ @@ -1179,6 +1181,30 @@ lazy val otel4sMetrics: ProjectMatrix = (projectMatrix in file("metrics/otel4s-m .jvmPlatform(scalaVersions = scala2_13And3Versions, settings = commonJvmSettings) .dependsOn(serverCore % CompileAndTest, catsEffect % Test) +lazy val otel4z: ProjectMatrix = (projectMatrix in file("observability/otel4z")) + .dependsOn(zio, zioHttpServer, opentelemetryMetrics) + .settings(commonSettings) + .settings( + name := "tapir-otel4z", + libraryDependencies ++= Seq( + "dev.zio" %% "zio-logging" % Versions.zioLogging, + "dev.zio" %% "zio-logging-slf4j2" % Versions.zioLogging, + "dev.zio" %% "zio-opentelemetry" % Versions.zioOpenTelemetry, + "dev.zio" %% "zio-opentelemetry-zio-logging" % Versions.zioOpenTelemetry, + "io.opentelemetry.semconv" % "opentelemetry-semconv" % Versions.openTelemetrySemconvVersion, + "io.opentelemetry" % "opentelemetry-sdk" % Versions.openTelemetry, + "io.opentelemetry" % "opentelemetry-exporter-otlp" % Versions.openTelemetry, + "io.opentelemetry" % "opentelemetry-exporter-logging-otlp" % Versions.openTelemetry, + "io.opentelemetry.instrumentation" % "opentelemetry-runtime-telemetry-java17" % Versions.openTelemetryRuntime, + "dev.zio" %% "zio-test" % Versions.zio % Test, + "dev.zio" %% "zio-test-sbt" % Versions.zio % Test, + + "io.opentelemetry" % "opentelemetry-sdk-testing" % Versions.openTelemetry % Test + ) + ) + .jvmPlatform(scalaVersions = scala2And3Versions, settings = commonJvmSettings) + .dependsOn(serverCore % CompileAndTest) + // docs lazy val apispecDocs: ProjectMatrix = (projectMatrix in file("docs/apispec-docs")) @@ -2361,7 +2387,8 @@ lazy val examples: ProjectMatrix = (projectMatrix in file("examples")) vertxServer, zioHttpServer, zioJson, - zioMetrics + zioMetrics, + otel4z ) //TODO this should be invoked by compilation process, see #https://github.com/scalameta/mdoc/issues/355 diff --git a/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip b/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip new file mode 100644 index 0000000000..824dc0adee --- /dev/null +++ b/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip @@ -0,0 +1,173 @@ +// {cat=Hello, World!; effects=ZIO; server=ZIO HTTP; json=zio; docs=Swagger UI}: ZIO OpenTelemetry tracing example + +//> using option -Xkind-projector +//> using dep com.softwaremill.sttp.tapir::tapir-core:1.13.18 +//> using dep com.softwaremill.sttp.tapir::tapir-json-circe:1.13.18 +//> using dep com.softwaremill.sttp.tapir::tapir-zio-http-server:1.13.18 +//> using dep com.softwaremill.sttp.tapir::tapir-swagger-ui-bundle:1.13.18 +//> using dep com.softwaremill.sttp.tapir::tapir-zio:1.13.18 +//> using dep com.softwaremill.sttp.tapir::tapir-otel4z:1.13.18 + +package sttp.tapir.examples.observability + +import io.opentelemetry.api.OpenTelemetry + +import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.o11y.otel4z._ +import sttp.tapir.server.ziohttp._ +import sttp.tapir.ztapir._ + +import zio._ +import zio.http._ +import zio.telemetry.opentelemetry.metrics.Meter +import zio.telemetry.opentelemetry.tracing.Tracing +import sttp.tapir.server.interceptor.cors.CORSInterceptor + + + + +/** This example demonstrates how to use ZIO with Tapir and OpenTelemetry for tracing. It sets up a simple HTTP server with a single + * endpoint that returns "Hello, World!" and includes tracing for incoming requests. + * + * To enable tracing, we use the ZIOpenTelemetry trait, which provides a Tracing service. + * + * To effectively produce traces, you need to set the OTEL_EXPORTER_OTLP_ENDPOINT environment variable to the address of your + * OpenTelemetry. + */ +object ZIOpenTelemetryExample extends ZIOApp with ZIOpenTelemetry("zio-observability-example") with Logging with Metrics with Traces { + + /** The server options for the ZIOOpenTelemetry trait. + * + * This is a separate method pulled by the bootstrap layer, as it is used to provide the OpenTelemetry layer to the server options, which + * are provided by the ZIO application itself. This allows the OpenTelemetry layer to be used for both the server options and the main + * program. + */ + + + + // The main program - start the server on port 8080 + val program = for + _ <- Console.printLine("Starting server on http://localhost:8080") + + given OpenTelemetry <- ZIO.service[OpenTelemetry] + + given Tracing <- ZIO.service[Tracing] + +// m <- ZIO.service[Meter] + + httpApi = ZIOHttpApi() + + endpoints = httpApi.endpoints + + httpApp = ZioHttpInterpreter(serverOptions).toHttp( + endpoints + ) + _ <- Server.serve(httpApp) + yield () + + /** Run the program. + * + * Provide the necessary layers for the program, including the ZIOOpenTelemetry layer and the server layer. + * + * Note that if not metric are exposed by a service, the meter layer will not be used, hence provideSomeLayer have to be used to ignore + * the meter layer (part of bootstrap layer Environment): + * {{{ + * override def run = + * program.provideSome[Environment]( + * Server.default + * ) + * }}} + */ + override def run = + program.provideSome[Environment]( + Scope.default, + Server.default, + + // This layers provides sample custom metric, which will be visible in the OpenTelemetry collector and can be used to verify that the metrics are working. + TickCounter.tickRefLayer, + TickCounter.tickCounterLayer, + // This layer provides the OpenTelemetry Metrics service. + // Can be used to create custom metrics. + // Note this will be different Meter instance than the one used by the ZIO runtime or Tapir. + otel4zMetrics(resourceName), + // This layer publishes ZIO logs to OpenTelemetry, which will be correlated with traces and metrics. + otel4zLogging(resourceName), + // This layer provides the OpenTelemetry Tracing service, + // which is used to create spans for incoming requests and other operations. + otel4zTracing(resourceName), + + ZIOpenTelemetry.runtimeTelemetry + + ) + + + /** The server options for the ZIOpenTelemetry trait. + * + * This is the server options that will be used to run the ZIO application, hence provided by bootstrap. It includes the OpenTelemetry + * instance and the ContextStorage. + */ + private def serverOptions(implicit + otel: OpenTelemetry, + tracing: Tracing + ): ZioHttpServerOptions[Any] = ZioHttpServerOptions.customiseInterceptors + .prependInterceptor( + ZIOtelTracing(tracing) + ) + .appendInterceptor( + CORSInterceptor.default + ) + .appendInterceptor(metricsInterceptor) + .serverLog( + ZioHttpServerOptions.defaultServerLog[Any] + ) + .options + } + + + +/** + * A simple counter that increments every second and is exposed as an OpenTelemetry metric. + * + * This is used to demonstrate how to create custom metrics + */ +object TickCounter { + val tickRefLayer: ULayer[Ref[Long]] = + ZLayer( + for { + ref <- Ref.make(0L) + _ <- ref + .update(_ + 1) + .repeat[Any, Long](Schedule.spaced(1.second)) + .forkDaemon + } yield ref + ) + + // Records the number of seconds elapsed since the application startup + val tickCounterLayer: RLayer[Meter & Ref[Long], Unit] = + ZLayer.scoped( + for { + meter <- ZIO.service[Meter] + ref <- ZIO.service[Ref[Long]] + // Initialize observable counter instrument + _ <- meter.observableCounter("tick_counter") { om => + for { + tick <- ref.get + _ <- om.record(tick) + } yield () + } + } yield () + ) +} + +class ZIOHttpApi(using tracing: Tracing) { + + val helloEndpoint: ServerEndpoint[Any, Task] = sttp.tapir.endpoint.get + .in("hello") + .out(stringBody) + .zServerLogic(_ => + ZIO.logInfo("Handling /hello request") *> + ZIO.succeed("Hello, World!") @@ tracing.aspects.span("hello-logic")) + + val endpoints: List[ServerEndpoint[Any, Task]] = List(helloEndpoint) + +} diff --git a/observability/otel4z/src/main/scala-2/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala b/observability/otel4z/src/main/scala-2/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala new file mode 100644 index 0000000000..788b24757c --- /dev/null +++ b/observability/otel4z/src/main/scala-2/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala @@ -0,0 +1,16 @@ +package sttp.tapir.server.o11y.otel4z + +import zio.ZIOApp + +/** ZIOpenTelemetry is a trait that provides a ZIO layer for OpenTelemetry. + * @param name + */ +trait ZIOpenTelemetry extends ZIOtelBase { + this: ZIOApp => +} + +trait ZIOpenTelemetryFull extends ZIOtelBase with Metrics with Traces { + this: ZIOApp => +} + +object ZIOpenTelemetry extends ZIOpenTelemetryBase \ No newline at end of file diff --git a/observability/otel4z/src/main/scala-3/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala b/observability/otel4z/src/main/scala-3/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala new file mode 100644 index 0000000000..8ba79bb206 --- /dev/null +++ b/observability/otel4z/src/main/scala-3/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala @@ -0,0 +1,17 @@ +package sttp.tapir.server.o11y.otel4z + +import zio.ZIOApp + +/** ZIOpenTelemetry is a trait that provides a ZIO layer for OpenTelemetry. + * @param name + */ +trait ZIOpenTelemetry(val resourceName: String) extends ZIOtelBase { + this: ZIOApp => +} + +trait ZIOpenTelemetryFull(val resourceName: String) extends ZIOtelBase with Metrics with Traces { + this: ZIOApp => +} + + +object ZIOpenTelemetry extends ZIOpenTelemetryBase \ No newline at end of file diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/LoggerProvider.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/LoggerProvider.scala new file mode 100644 index 0000000000..3f07df6b37 --- /dev/null +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/LoggerProvider.scala @@ -0,0 +1,50 @@ +package sttp.tapir.server.o11y.otel4z + +import io.opentelemetry.api.common.Attributes + +import io.opentelemetry.sdk.logs.SdkLoggerProvider +import io.opentelemetry.sdk.logs.`export`.SimpleLogRecordProcessor +import io.opentelemetry.sdk.resources.Resource +import zio._ + +import io.opentelemetry.semconv.ServiceAttributes +import io.opentelemetry.exporter.otlp.logs.OtlpGrpcLogRecordExporter + +object LoggerProvider extends OtlpEndpoint { + + /** gRPC exporter that sends logs to the endpoint specified in the environment variable. + * + * If the environment variable is not set, will fallback OTEL_EXPORTER_OTLP_ENDPOINT env var or "http://localhost:4317". + */ + def grpc(resourceName: String): URIO[Scope, Option[SdkLoggerProvider]] = + for { + logRecordExporter <- + ZIO.fromAutoCloseable( + ZIO.succeed( + OtlpGrpcLogRecordExporter + .builder() + .setEndpoint(getEndpoint("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT")) + .build() + ) + ) + logRecordProcessor <- + ZIO.fromAutoCloseable( + ZIO.succeed(SimpleLogRecordProcessor.create(logRecordExporter)) + ) + loggerProvider <- + ZIO.fromAutoCloseable( + ZIO.succeed( + SdkLoggerProvider + .builder() + .setResource( + Resource.create( + Attributes.of(ServiceAttributes.SERVICE_NAME, resourceName) + ) + ) + .addLogRecordProcessor(logRecordProcessor) + .build() + ) + ) + } yield Some(loggerProvider) + +} diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/MeterProvider.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/MeterProvider.scala new file mode 100644 index 0000000000..d3a14a7759 --- /dev/null +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/MeterProvider.scala @@ -0,0 +1,80 @@ +package sttp.tapir.server.o11y.otel4z + +import zio._ +import io.opentelemetry.sdk.metrics.SdkMeterProvider +import io.opentelemetry.sdk.metrics.`export`.PeriodicMetricReader +import io.opentelemetry.sdk.resources.Resource +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.semconv.ServiceAttributes +import io.opentelemetry.exporter.logging.otlp.OtlpJsonLoggingMetricExporter +import io.opentelemetry.exporter.otlp.metrics.OtlpGrpcMetricExporter + +object MeterProvider extends OtlpEndpoint { + + /** Prints to stdout in OTLP Json format + */ + def stdout(resourceName: String): RIO[Scope, SdkMeterProvider] = + for { + metricExporter <- ZIO.fromAutoCloseable( + ZIO.succeed(OtlpJsonLoggingMetricExporter.create()) + ) + metricReader <- + ZIO.fromAutoCloseable( + ZIO.succeed( + PeriodicMetricReader + .builder(metricExporter) + .setInterval(5.second) + .build() + ) + ) + meterProvider <- + ZIO.fromAutoCloseable( + ZIO.succeed( + SdkMeterProvider + .builder() + .registerMetricReader(metricReader) + .setResource( + Resource.create( + Attributes.of(ServiceAttributes.SERVICE_NAME, resourceName) + ) + ) + .build() + ) + ) + } yield meterProvider + + /** gRPC exporter that sends metrics to the endpoint specified in the environment variable. + * + * If the environment variable is not set, will fallback OTEL_EXPORTER_OTLP_ENDPOINT env var or "http://localhost:4317". + */ + def grpc(resourceName: String): URIO[Scope, Option[SdkMeterProvider]] = + for { + metricExporter <- ZIO.fromAutoCloseable( + ZIO.succeed(OtlpGrpcMetricExporter.builder().setEndpoint(getEndpoint("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT")).build()) + ) + metricReader <- + ZIO.fromAutoCloseable( + ZIO.succeed( + PeriodicMetricReader + .builder(metricExporter) + .setInterval(5.second) + .build() + ) + ) + meterProvider <- + ZIO.fromAutoCloseable( + ZIO.succeed( + SdkMeterProvider + .builder() + .registerMetricReader(metricReader) + .setResource( + Resource.create( + Attributes.of(ServiceAttributes.SERVICE_NAME, resourceName) + ) + ) + .build() + ) + ) + } yield Some(meterProvider) + +} diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/OtlpEndpoint.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/OtlpEndpoint.scala new file mode 100644 index 0000000000..b42c334fcc --- /dev/null +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/OtlpEndpoint.scala @@ -0,0 +1,25 @@ +package sttp.tapir.server.o11y.otel4z + +trait OtlpEndpoint { + + /** OTLP gRPC endpoint to export telemetry data to. + * + * It can be set via: + * + * - environment variable provided as `envVar` + * - environment variable "OTEL_EXPORTER_OTLP_ENDPOINT" + * - defaults to "http://localhost:4317" + * + * See https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/#otel_exporter_otlp_endpoint. + * + * @param envVar + * @return + */ + protected def getEndpoint(envVar: String): String = + sys.env + .get(envVar) + .orElse(sys.env.get("OTEL_EXPORTER_OTLP_ENDPOINT")) + .getOrElse( + "http://localhost:4317" + ) +} diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/Providers.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/Providers.scala new file mode 100644 index 0000000000..9ec20b053d --- /dev/null +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/Providers.scala @@ -0,0 +1,79 @@ +package sttp.tapir.server.o11y.otel4z + +import zio._ + + +import io.opentelemetry.sdk.metrics.SdkMeterProvider +import io.opentelemetry.sdk.trace.SdkTracerProvider +import zio.telemetry.opentelemetry.OpenTelemetry +import io.opentelemetry.api +import sttp.tapir.server.metrics.opentelemetry.OpenTelemetryMetrics +import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor +import io.opentelemetry.sdk.logs.SdkLoggerProvider + + +trait Logging { + this: ZIOtelBase => + + override def logProvider: URIO[Scope, Option[SdkLoggerProvider]] = LoggerProvider.grpc(resourceName) + + def otel4zLogging( instrumentationScopeName: String, + logLevel: LogLevel = LogLevel.Info) = OpenTelemetry.logging( + instrumentationScopeName = instrumentationScopeName, + logLevel = logLevel + ) +} + +trait Metrics { + this: ZIOtelBase => + + + override def meterProvider: URIO[Scope, Option[SdkMeterProvider]] = MeterProvider.grpc(resourceName) + + def otel4zMetrics( + instrumentationScopeName: String, + instrumentationVersion: Option[String] = None, + schemaUrl: Option[String] = None, + logAnnotated: Boolean = false + ) = OpenTelemetry.metrics( + instrumentationScopeName = instrumentationScopeName, + instrumentationVersion = instrumentationVersion, + schemaUrl = schemaUrl, + logAnnotated = logAnnotated + ) + + def metricsInterceptor(using otel: api.OpenTelemetry): MetricsRequestInterceptor[Task] = { + val meter: api.metrics.Meter = otel.meterBuilder("tapir").build() + + val metrics = OpenTelemetryMetrics.default[Task](meter) + + metrics.metricsInterceptor() + } + +} + +object Metrics { + def live(instrumentName : String) = OpenTelemetry.metrics(instrumentName) +} + +trait Traces { + this: ZIOtelBase => + + override def tracerProvider: URIO[Scope, Option[SdkTracerProvider]] = TracerProvider.grpc(resourceName) + + def otel4zTracing(instrumentationScopeName: String, + instrumentationVersion: Option[String] = None, + schemaUrl: Option[String] = None, + logAnnotated: Boolean = false + ) = OpenTelemetry.tracing( + instrumentationScopeName = instrumentationScopeName, + instrumentationVersion = instrumentationVersion, + schemaUrl = schemaUrl, + logAnnotated = logAnnotated + ) +} + +object Traces { + def live(instrumentName : String) = OpenTelemetry.tracing(instrumentName) + +} \ No newline at end of file diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/TracerProvider.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/TracerProvider.scala new file mode 100644 index 0000000000..1e5c35ea87 --- /dev/null +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/TracerProvider.scala @@ -0,0 +1,94 @@ +package sttp.tapir.server.o11y.otel4z + +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter +import io.opentelemetry.sdk.resources.Resource +import io.opentelemetry.sdk.trace.SdkTracerProvider +import io.opentelemetry.sdk.trace.`export`.SimpleSpanProcessor +import io.opentelemetry.semconv.ServiceAttributes +import zio._ +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter +import io.opentelemetry.exporter.logging.otlp.OtlpJsonLoggingSpanExporter + +object TracerProvider extends OtlpEndpoint { + + /** Prints to stdout in OTLP Json format + */ + def stdout(resourceName: String): RIO[Scope, SdkTracerProvider] = + for { + spanExporter <- + ZIO.fromAutoCloseable(ZIO.succeed(OtlpJsonLoggingSpanExporter.create())) + spanProcessor <- ZIO.fromAutoCloseable( + ZIO.succeed(SimpleSpanProcessor.create(spanExporter)) + ) + tracerProvider <- + ZIO.fromAutoCloseable( + ZIO.succeed( + SdkTracerProvider + .builder() + .setResource( + Resource.create( + Attributes.of(ServiceAttributes.SERVICE_NAME, resourceName) + ) + ) + .addSpanProcessor(spanProcessor) + .build() + ) + ) + } yield tracerProvider + + /** gRPC exporter that sends spans to the endpoint specified in the environment variable. + * + * If the environment variable is not set, will fallback OTEL_EXPORTER_OTLP_ENDPOINT env var or "http://localhost:4317". + */ + def grpc(resourceName: String): URIO[Scope, Option[SdkTracerProvider]] = + for { + spanExporter <- ZIO.fromAutoCloseable( + ZIO.succeed(OtlpGrpcSpanExporter.builder().setEndpoint(getEndpoint("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")).build()) + ) + spanProcessor <- ZIO.fromAutoCloseable( + ZIO.succeed(SimpleSpanProcessor.create(spanExporter)) + ) + tracerProvider <- + ZIO.fromAutoCloseable( + ZIO.succeed( + SdkTracerProvider + .builder() + .setResource( + Resource.create( + Attributes.of(ServiceAttributes.SERVICE_NAME, resourceName) + ) + ) + .addSpanProcessor(spanProcessor) + .build() + ) + ) + } yield Some(tracerProvider) + + /** https://fluentbit.io/ + */ + def fluentbit(resourceName: String): RIO[Scope, SdkTracerProvider] = + for { + spanExporter <- ZIO.fromAutoCloseable( + ZIO.succeed(OtlpHttpSpanExporter.builder().build()) + ) + spanProcessor <- ZIO.fromAutoCloseable( + ZIO.succeed(SimpleSpanProcessor.create(spanExporter)) + ) + tracerProvider <- + ZIO.fromAutoCloseable( + ZIO.succeed( + SdkTracerProvider + .builder() + .setResource( + Resource.create( + Attributes.of(ServiceAttributes.SERVICE_NAME, resourceName) + ) + ) + .addSpanProcessor(spanProcessor) + .build() + ) + ) + } yield tracerProvider + +} diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryBase.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryBase.scala new file mode 100644 index 0000000000..e95e3e1cc1 --- /dev/null +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryBase.scala @@ -0,0 +1,19 @@ +package sttp.tapir.server.o11y.otel4z + +import zio._ +import io.opentelemetry.instrumentation.runtimetelemetry.RuntimeTelemetry + +trait ZIOpenTelemetryBase { + + + def runtimeTelemetry = ZLayer.fromZIO( + for { + openTelemetry <- ZIO.service[io.opentelemetry.api.OpenTelemetry] + _ <- ZIO.fromAutoCloseable(ZIO.succeed(RuntimeTelemetry.create(openTelemetry))) + } yield () + ) + + + + +} diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelBase.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelBase.scala new file mode 100644 index 0000000000..b1c974126c --- /dev/null +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelBase.scala @@ -0,0 +1,84 @@ +package sttp.tapir.server.o11y.otel4z + +import io.opentelemetry.api +import zio._ +import zio.logging.backend.SLF4J +import zio.telemetry.opentelemetry.context.ContextStorage + +import zio.telemetry.opentelemetry.OpenTelemetry + +import io.opentelemetry.sdk.trace.SdkTracerProvider +import io.opentelemetry.sdk.metrics.SdkMeterProvider +import io.opentelemetry.sdk.logs.SdkLoggerProvider + + +/** ZIOTelBase is a trait that provides a ZIO layer for OpenTelemetry as bootstrap. + * + * By default, it uses the OTEL_EXPORTER_OTLP_ENDPOINT environment variable to configure the OpenTelemetry exporter. + * + * - Uses SLF4J for logging to stdout. + * - Logs, Metrics, Traces are sent to the OpenTelemetry collector through gRPC. + */ +protected trait ZIOtelBase { + this: ZIOApp => + + /** The name of the resource, advertised to the OpenTelemetry collector. */ + def resourceName: String + + def withZIOMetrics: Boolean = true + + + /** The environment for the ZIOpenTelemetry trait. + * + * This is the environment that will be used to run the ZIO application, hence provided by bootstrap. + * + * It includes: + * - the OpenTelemetry instance. + * - the ContextStorage instance. + */ + override type Environment = api.OpenTelemetry with ContextStorage + + /** The tag for the ZIOpenTelemetry trait. */ + def environmentTag: Tag[Environment] = + Tag[Environment] + + + /** + * The console log layer for the ZIOpenTelemetry trait. + * + * Default implementation uses SLF4J for logging to stdout. + */ + def consoleLogLayer: ZLayer[Any, Nothing, Unit] = Runtime.removeDefaultLoggers >>> SLF4J.slf4j + + /** + * The OpenTelemetry providers for the ZIOpenTelemetry trait. + * + * @return + */ + def logProvider: URIO[Scope, Option[SdkLoggerProvider]] = ZIO.none + + def meterProvider: URIO[Scope, Option[SdkMeterProvider]] = ZIO.none + + def tracerProvider: URIO[Scope, Option[SdkTracerProvider]] = ZIO.none + + + final def otelProviders: URIO[Scope, OtelProviders] = for { + logger <- logProvider + meter <- meterProvider + tracer <- tracerProvider + } yield OtelProviders(tracer, meter, logger) + + /** The bootstrap layer for the ZIOpenTelemetry trait. + * + * This is the layer that will be used to bootstrap the ZIO application. It includes the OpenTelemetry layer, the Tracing layer, and the + * Meter layer. + */ + override val bootstrap: ZLayer[ZIOAppArgs, Any, Environment] = + consoleLogLayer >>> + OpenTelemetry.contextZIO >+> (ZLayer.scoped(otelProviders) >>> + ZIOtelLayer + .live(withZIOMetrics)) + + + +} diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelLayer.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelLayer.scala new file mode 100644 index 0000000000..6fc3f37ace --- /dev/null +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelLayer.scala @@ -0,0 +1,70 @@ +package sttp.tapir.server.o11y.otel4z + +import zio._ +import io.opentelemetry.api +import io.opentelemetry.sdk.OpenTelemetrySdk +import io.opentelemetry.sdk.logs.SdkLoggerProvider +import io.opentelemetry.sdk.metrics.SdkMeterProvider +import io.opentelemetry.sdk.trace.SdkTracerProvider +import zio.telemetry.opentelemetry.OpenTelemetry +import zio.telemetry.opentelemetry.context.ContextStorage + + +/** + * OtelProviders is a case class that holds the OpenTelemetry providers for tracing, metrics and logging. + * + * It is used to build the OpenTelemetry + * + * @param tracerProvider + * @param meterProvider + * @param loggerProvider + */ +case class OtelProviders( + tracerProvider: Option[SdkTracerProvider], + meterProvider: Option[SdkMeterProvider], + loggerProvider: Option[SdkLoggerProvider], +){ + + + def build(): OpenTelemetrySdk = { + val builder =OpenTelemetrySdk + .builder() + tracerProvider.foreach(builder.setTracerProvider) + meterProvider.foreach(builder.setMeterProvider) + loggerProvider.foreach(builder.setLoggerProvider) + builder.build() + } + + def withRuntimeTelemetry: Boolean = meterProvider.isDefined +} + +object ZIOtelLayer { + + + /** + * The OpenTelemetry layer for the ZIOpenTelemetry trait. + * + * This is a separate method pulled by the bootstrap layer, as it is used to provide the OpenTelemetry layer to the server options, which + * are provided by the ZIO application itself. This allows the OpenTelemetry layer to be used + * + * @param resourceName + * @return + */ + def live(withZioMetrics: Boolean): RLayer[OtelProviders with ContextStorage, api.OpenTelemetry] = + if (withZioMetrics) + otel >+> (OpenTelemetry.metrics("zio") >>> OpenTelemetry.zioMetrics) + else otel + + private def otel = ZLayer.scoped[OtelProviders]( + for { + otelProviders <- ZIO.service[OtelProviders] + openTelemetry <- ZIO.fromAutoCloseable( + ZIO.succeed(otelProviders.build()) + ) + + } yield openTelemetry + ) + + + +} diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracing.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracing.scala new file mode 100644 index 0000000000..24dc5ef1d5 --- /dev/null +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracing.scala @@ -0,0 +1,211 @@ +package sttp.tapir.server.o11y.otel4z + +import zio._ + +import sttp.monad.MonadError +import sttp.model.{StatusCode => SttpStatusCode} +import sttp.tapir.AnyEndpoint +import sttp.tapir.model.ServerRequest +import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.interceptor.RequestResult.{Failure, Response} +import sttp.tapir.server.interceptor._ +import sttp.tapir.server.interpreter.BodyListener +import sttp.tapir.server.model.ServerResponse + +import io.opentelemetry.api.trace.Span + +import zio.telemetry.opentelemetry.tracing.Tracing +import io.opentelemetry.api.trace.SpanKind + +import io.opentelemetry.api.trace.StatusCode +import io.opentelemetry.api.common.Attributes + +/** Interceptor which traces requests using otel4s. + * + * Span names and attributes are calculated using the provided [[ZIOtelTracingConfig]]. + * + * To use, customize the interceptors of the server interpreter you are using, and prepend this interceptor, so that it runs as early as + * possible, e.g.: + * + * {{{ + * protected def serverOptions(using + * tracing: Tracing + * ): ZioHttpServerOptions[Any] = + * ZioHttpServerOptions.customiseInterceptors + * .prependInterceptor( + * ZIOpenTelemetryTracing(tracing) + * ) + * .appendInterceptor( + * CORSInterceptor.default + * ) + * .serverLog( + * ZioHttpServerOptions.defaultServerLog + * ) + * .options + * }}} + */ + +class ZIOtelTracing( + tracing: Tracing, + config: ZIOtelTracingConfig +) extends RequestInterceptor[Task] { + + import config._ + + override def apply[R, B]( + responder: Responder[Task, B], + requestHandler: EndpointInterceptor[Task] => RequestHandler[Task, R, B] + ): RequestHandler[Task, R, B] = + + new RequestHandler[Task, R, B] { + override def apply( + request: ServerRequest, + endpoints: List[ServerEndpoint[R, Task]] + )(implicit monad: MonadError[Task]): Task[RequestResult[B]] = tracing + .extractSpanUnsafe( + config.propagator, + config.carrier, + request.showShort, + spanKind = SpanKind.SERVER, + attributes = config.requestAttributes(request) + ) + .flatMap { case (span, finalize) => + handleRequest(span, request, endpoints) + .tapError(th => spanError(span)(Right(th))) + .ensuring(finalize) + } + + /** Handle the request, setting span attributes and status based on the result. + * + * @param span + * @param request + * @param endpoints + * @param monad + * @return + */ + def handleRequest( + span: Span, + request: ServerRequest, + endpoints: List[ServerEndpoint[R, Task]] + )(implicit monad: MonadError[Task]) = + for { + requestResult <- requestHandler( + knownEndpointInterceptor(request, span) + )(request, endpoints) + _ <- requestResult match { + case Response(response, _) => + setSpanAttibutes( + span, + responseAttributes(request, response) + ) *> ZIO.when(response.isServerError)( + spanError(span)(Left(response.code)) + ) + case Failure(_) => + // ignore, request not handled + ZIO.unit + } + } yield requestResult + + /** Interceptor which sets span name and attributes based on the matched endpoint. + * + * @param request + * @param span + * @return + */ + def knownEndpointInterceptor( + request: ServerRequest, + span: Span + ) = + new EndpointInterceptor[Task] { + def apply[B2]( + responder: Responder[Task, B2], + endpointHandler: EndpointHandler[Task, B2] + ): EndpointHandler[Task, B2] = new EndpointHandler[Task, B2] { + def onDecodeFailure( + ctx: DecodeFailureContext + )(implicit + monad: MonadError[Task], + bodyListener: BodyListener[Task, B2] + ): Task[Option[ServerResponse[B2]]] = + endpointHandler.onDecodeFailure(ctx).flatMap { + case result @ Some(_) => + knownEndpoint(ctx.endpoint).map(_ => result) + case None => monad.unit(None) + } + + def onDecodeSuccess[A, U, I]( + ctx: DecodeSuccessContext[Task, A, U, I] + )(implicit + monad: MonadError[Task], + bodyListener: BodyListener[Task, B2] + ): Task[ServerResponse[B2]] = + knownEndpoint(ctx.endpoint).flatMap(_ => endpointHandler.onDecodeSuccess(ctx)) + + def onSecurityFailure[A]( + ctx: SecurityFailureContext[Task, A] + )(implicit + monad: MonadError[Task], + bodyListener: BodyListener[Task, B2] + ): Task[ServerResponse[B2]] = + knownEndpoint(ctx.endpoint).flatMap(_ => endpointHandler.onSecurityFailure(ctx)) + + def knownEndpoint( + e: AnyEndpoint + ): Task[Unit] = { + val (name, attributes) = + spanNameFromEndpointAndAttributes(request, e) + ZIO.succeed { + span + .updateName(name) + span.setAllAttributes(attributes) + }.unit + } + } + } + + /** Set span status and attributes for errors, both exceptions and error status. + */ + private def spanError( + span: Span + )(error: Either[SttpStatusCode, Throwable]): Task[Unit] = + ZIO.succeed { + span.setStatus(StatusCode.ERROR) + span.setAllAttributes(errorAttributes(error)) + }.unit + + private def setSpanAttibutes( + span: Span, + attributes: Attributes + ): Task[Unit] = + ZIO.succeed(span.setAllAttributes(attributes)).unit + + } +} + +object ZIOtelTracing { + + /** Create a new ZIOpenTelemetryTracing interceptor with the provided Tracing and default configuration. + * + * @param tracing + * @return + */ + def apply( + tracing: Tracing + ): ZIOtelTracing = + new ZIOtelTracing( + tracing, + ZIOtelTracingConfig() + ) + + /** Create a new ZIOpenTelemetryTracing interceptor with the provided Tracing and configuration. + */ + def apply( + tracing: Tracing, + config: ZIOtelTracingConfig + ): ZIOtelTracing = + new ZIOtelTracing( + tracing, + config + ) + +} diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingConfig.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingConfig.scala new file mode 100644 index 0000000000..1e02da2a6a --- /dev/null +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingConfig.scala @@ -0,0 +1,142 @@ +package sttp.tapir.server.o11y.otel4z + +import sttp.model.headers.{Forwarded, Host} +import sttp.model.{HeaderNames, StatusCode} +import sttp.tapir.AnyEndpoint +import sttp.tapir.model.ServerRequest +import sttp.tapir.server.model.ServerResponse + +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.semconv.HttpAttributes +import io.opentelemetry.semconv.UrlAttributes +import io.opentelemetry.semconv.ServerAttributes +import io.opentelemetry.semconv.ErrorAttributes +import scala.annotation.nowarn +import zio.telemetry.opentelemetry.tracing.propagation.TraceContextPropagator +import zio.telemetry.opentelemetry.context.IncomingContextCarrier + +/** Configuration for OpenTelemetry Otel4z tracing of server requests, used by [[ZIOpenTelemetry]]. Use the apply method to override only + * some of the configuration options, while using the defaults for the rest. + * + * The default values follow OpenTelemetry semantic conventions, as described in [their + * documentation](https://opentelemetry.io/docs/specs/semconv/http/http-spans/#name). + * + * @param tracing + * The tracing instance to use. To obtain it see + * + * @param spanName + * Calculates the name of the span, given an incoming request. + * @param requestAttributes + * Calculates the attributes of the span, given an incoming request. + * @param spanNameFromEndpointAndAttributes + * Calculates an updated name of the span and additional attributes, once (and if) an endpoint is determined to handle the request. By + * default, the span name includes the request's method and the route, which is created by rendering the endpoint's path template. + * @param responseAttributes + * Calculates additional attributes of the span, given a response that will be sent back. + * @param errorAttributes + * Calculates additional attributes of the span, given an error that occurred while processing the request (an exception); although + * usually, exceptions are translated into 5xx responses earlier in the interceptor chain. + */ +case class ZIOtelTracingConfig( + propagator: TraceContextPropagator, + carrier: IncomingContextCarrier[ + scala.collection.mutable.Map[String, String] + ], + + spanName: ServerRequest => String, + requestAttributes: ServerRequest => Attributes, + spanNameFromEndpointAndAttributes: (ServerRequest, AnyEndpoint) => ( + String, + Attributes + ), + responseAttributes: (ServerRequest, ServerResponse[?]) => Attributes, + errorAttributes: Either[StatusCode, Throwable] => Attributes +) + +object ZIOtelTracingConfig { + def apply( + propagator: TraceContextPropagator = TraceContextPropagator.default, + carrier: IncomingContextCarrier[ + scala.collection.mutable.Map[String, String] + ] = IncomingContextCarrier.default(), + spanName: ServerRequest => String = Defaults.spanName, + requestAttributes: ServerRequest => Attributes = Defaults.requestAttributes, + spanNameFromEndpointAndAttributes: (ServerRequest, AnyEndpoint) => ( + String, + Attributes + ) = Defaults.spanNameFromEndpointAndAttributes, + responseAttributes: (ServerRequest, ServerResponse[?]) => Attributes = Defaults.responseAttributes, + errorAttributes: Either[StatusCode, Throwable] => Attributes = Defaults.errorAttributes + ): ZIOtelTracingConfig = + new ZIOtelTracingConfig( + propagator, + carrier, + spanName, + requestAttributes, + spanNameFromEndpointAndAttributes, + responseAttributes, + errorAttributes + ) + + /** @see + * https://opentelemetry.io/docs/specs/semconv/http/http-spans/#name + * @see + * https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-server + */ + object Defaults { + def spanNameFromEndpointAndAttributes( + request: ServerRequest, + endpoint: AnyEndpoint + ): (String, Attributes) = { + val route = endpoint.showPathTemplate(showQueryParam = None) + val name = s"${request.method.method} $route" + (name, Attributes.of(HttpAttributes.HTTP_ROUTE, route)) + } + + def requestAttributes(request: ServerRequest): Attributes = { + val hostHeader: String = request + .header(HeaderNames.Forwarded) + .flatMap(f => Forwarded.parse(f).toOption.flatMap(_.headOption).flatMap(_.host)) + .orElse(request.header(HeaderNames.XForwardedHost)) + .orElse(request.header(":authority")) + .orElse(request.header(HeaderNames.Host)) + .getOrElse("unknown") + + val (host, _) = Host.parseHostAndPort(hostHeader) + + Attributes.of( + HttpAttributes.HTTP_REQUEST_METHOD, + request.method.method, + UrlAttributes.URL_PATH, + request.uri.pathToString, + UrlAttributes.URL_SCHEME, + request.uri.scheme.getOrElse("http"), + ServerAttributes.SERVER_ADDRESS, + host + ) + + } + + def spanName(request: ServerRequest): String = s"${request.method.method}" + + @nowarn + def responseAttributes( + request: ServerRequest, + response: ServerResponse[_] + ): Attributes = + Attributes.of( + HttpAttributes.HTTP_RESPONSE_STATUS_CODE, + response.code.code.toLong.asInstanceOf[java.lang.Long] + ) + + def errorAttributes(error: Either[StatusCode, Throwable]): Attributes = + error match { + case Left(statusCode) => + // see footnote for error.type + Attributes.of(ErrorAttributes.ERROR_TYPE, statusCode.code.toString) + case Right(exception) => + val errorType = exception.getClass.getSimpleName + Attributes.of(ErrorAttributes.ERROR_TYPE, errorType) + } + } +} diff --git a/observability/otel4z/src/test/scala-2/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTestApp.scala b/observability/otel4z/src/test/scala-2/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTestApp.scala new file mode 100644 index 0000000000..19d2b17027 --- /dev/null +++ b/observability/otel4z/src/test/scala-2/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTestApp.scala @@ -0,0 +1,8 @@ +package sttp.tapir.server.o11y.otel4z + +import zio._ + +object TestZIOApp extends ZIOApp with ZIOpenTelemetry { + override def resourceName: String = "test-service" + override def run = ZIO.unit +} diff --git a/observability/otel4z/src/test/scala-3/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTestApp.scala b/observability/otel4z/src/test/scala-3/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTestApp.scala new file mode 100644 index 0000000000..1dd2cdd304 --- /dev/null +++ b/observability/otel4z/src/test/scala-3/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTestApp.scala @@ -0,0 +1,8 @@ +package sttp.tapir.server.o11y.otel4z + +import zio.* + +object TestZIOApp extends ZIOApp with ZIOpenTelemetry("test-service") { + + override def run = ZIO.unit +} diff --git a/observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala b/observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala new file mode 100644 index 0000000000..49cfffad6d --- /dev/null +++ b/observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala @@ -0,0 +1,96 @@ +package sttp.tapir.server.o11y.otel4s + +import scala.util.{Success, Try} + +import sttp.capabilities.Streams +import sttp.model._ +import sttp.model.Uri._ +import sttp.monad.MonadError +import sttp.tapir._ +import sttp.tapir.TestUtil.serverRequestFromUri +import sttp.tapir.capabilities.NoStreams +import sttp.tapir.model.ServerRequest +import sttp.tapir.server.interpreter._ +import sttp.tapir.server.o11y.otel4z.ZIOtelTracing +import sttp.tapir.server.TestUtil.StringToResponseBody + +import io.opentelemetry.api.trace.Tracer +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter +import io.opentelemetry.sdk.trace.`export`.SimpleSpanProcessor +import io.opentelemetry.sdk.trace.SdkTracerProvider + +import zio._ +import zio.telemetry.opentelemetry.context.ContextStorage +import zio.telemetry.opentelemetry.tracing.Tracing +import zio.test._ +import zio.test.Assertion._ + + +import sttp.tapir.ztapir.RIOMonadError +import zio.telemetry.opentelemetry.OpenTelemetry + +object ZIOtelTracingTest extends ZIOSpecDefault { + + implicit val bodyListener: BodyListener[Task, String] = new BodyListener[Task, String] { + override def onComplete(body: String)(cb: Try[Unit] => Task[Unit]): Task[String] = cb(Success(())).map(_ => body) + } + + implicit val ioErr: MonadError[Task] = new RIOMonadError + + val inMemoryTracer: UIO[(InMemorySpanExporter, Tracer)] = for { + spanExporter <- ZIO.succeed(InMemorySpanExporter.create()) + spanProcessor <- ZIO.succeed(SimpleSpanProcessor.create(spanExporter)) + tracerProvider <- ZIO.succeed(SdkTracerProvider.builder().addSpanProcessor(spanProcessor).build()) + tracer = tracerProvider.get("TracingTest") + } yield (spanExporter, tracer) + + val inMemoryTracerLayer: ULayer[InMemorySpanExporter with Tracer] = + ZLayer.fromZIOEnvironment(inMemoryTracer.map { case (inMemorySpanExporter, tracer) => + ZEnvironment(inMemorySpanExporter).add(tracer) + }) + + def tracingMockLayer( + logAnnotated: Boolean = false + ): URLayer[ContextStorage, Tracing with InMemorySpanExporter with Tracer] = + inMemoryTracerLayer >>> (Tracing.live(logAnnotated) ++ inMemoryTracerLayer) + + def spec: Spec[Any, Throwable] = + suite("zio opentelemetry tapir interceptor")(test("report a simple trace") { + for { + _ <- ZIO.logDebug("Setting up in-memory tracer and tracing layer") + tracing <- ZIO.service[Tracing] + endpointa = endpoint + .in("person") + .in(query[String]("name")) + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.succeed(Right("hello"))) + + request = serverRequestFromUri(uri"http://example.com/person?name=Adam") + interpreter = new ServerInterpreter[Any, Task, String, NoStreams]( + _ => List(endpointa), + ZIOTestRequestBody, + StringToResponseBody, + List(ZIOtelTracing(tracing)), + _ => ZIO.succeed(()) + ) + _ <- interpreter(request) + + exported <- ZIO.service[InMemorySpanExporter] + + } yield { + + assert(exported.getFinishedSpanItems.isEmpty())(isFalse) + } + + }).provide( + OpenTelemetry.contextZIO, + tracingMockLayer(false) + ) +} + +object ZIOTestRequestBody extends RequestBody[Task, NoStreams] { + override def toRaw[R](serverRequest: ServerRequest, bodyType: RawBodyType[R], maxBytes: Option[Long]): Task[RawValue[R]] = ??? + override val streams: Streams[NoStreams] = NoStreams + override def toStream(serverRequest: ServerRequest, maxBytes: Option[Long]): streams.BinaryStream = ??? +} diff --git a/project/Versions.scala b/project/Versions.scala index 1b724ffd82..46a0768902 100644 --- a/project/Versions.scala +++ b/project/Versions.scala @@ -43,6 +43,8 @@ object Versions { val zioInteropCats = "23.1.0.13" val zioInteropReactiveStreams = "2.0.2" val zioJson = "0.7.44" + val zioLogging = "2.5.2" + val zioOpenTelemetry = "3.1.15" val playClient = "3.0.12" val playServer = "3.0.10" val play29Client = "2.2.16" @@ -64,7 +66,8 @@ object Versions { val decline = "2.6.2" val quicklens = "1.9.12" val openTelemetry = "1.62.0" - val openTelemetrySemconvVersion = "1.41.1" + val openTelemetryRuntime = "2.27.0-alpha" + val openTelemetrySemconvVersion = "1.41.0" val mockServer = "5.15.0" val dogstatsdClient = "4.4.5" val nettyAll = "4.2.13.Final" From 3e4ebb68d09a4326baddc367e9f28f41b087deb4 Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Fri, 15 May 2026 22:46:41 +0200 Subject: [PATCH 02/17] Refactor formatting and indentation in Providers.scala for improved readability --- .../tapir/server/o11y/otel4z/Providers.scala | 65 +++++++++---------- 1 file changed, 31 insertions(+), 34 deletions(-) diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/Providers.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/Providers.scala index 9ec20b053d..227579d1f4 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/Providers.scala +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/Providers.scala @@ -2,7 +2,6 @@ package sttp.tapir.server.o11y.otel4z import zio._ - import io.opentelemetry.sdk.metrics.SdkMeterProvider import io.opentelemetry.sdk.trace.SdkTracerProvider import zio.telemetry.opentelemetry.OpenTelemetry @@ -11,69 +10,67 @@ import sttp.tapir.server.metrics.opentelemetry.OpenTelemetryMetrics import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor import io.opentelemetry.sdk.logs.SdkLoggerProvider - trait Logging { this: ZIOtelBase => override def logProvider: URIO[Scope, Option[SdkLoggerProvider]] = LoggerProvider.grpc(resourceName) - def otel4zLogging( instrumentationScopeName: String, - logLevel: LogLevel = LogLevel.Info) = OpenTelemetry.logging( - instrumentationScopeName = instrumentationScopeName, - logLevel = logLevel - ) + def otel4zLogging(instrumentationScopeName: String, logLevel: LogLevel = LogLevel.Info) = OpenTelemetry.logging( + instrumentationScopeName = instrumentationScopeName, + logLevel = logLevel + ) } trait Metrics { - this: ZIOtelBase => + this: ZIOtelBase => - - override def meterProvider: URIO[Scope, Option[SdkMeterProvider]] = MeterProvider.grpc(resourceName) + override def meterProvider: URIO[Scope, Option[SdkMeterProvider]] = MeterProvider.grpc(resourceName) - def otel4zMetrics( + def otel4zMetrics( instrumentationScopeName: String, instrumentationVersion: Option[String] = None, schemaUrl: Option[String] = None, logAnnotated: Boolean = false - ) = OpenTelemetry.metrics( - instrumentationScopeName = instrumentationScopeName, - instrumentationVersion = instrumentationVersion, - schemaUrl = schemaUrl, - logAnnotated = logAnnotated - ) + ) = OpenTelemetry.metrics( + instrumentationScopeName = instrumentationScopeName, + instrumentationVersion = instrumentationVersion, + schemaUrl = schemaUrl, + logAnnotated = logAnnotated + ) - def metricsInterceptor(using otel: api.OpenTelemetry): MetricsRequestInterceptor[Task] = { - val meter: api.metrics.Meter = otel.meterBuilder("tapir").build() + def metricsInterceptor(implicit otel: api.OpenTelemetry): MetricsRequestInterceptor[Task] = { + val meter: api.metrics.Meter = otel.meterBuilder("tapir").build() - val metrics = OpenTelemetryMetrics.default[Task](meter) + val metrics = OpenTelemetryMetrics.default[Task](meter) - metrics.metricsInterceptor() - } + metrics.metricsInterceptor() + } } object Metrics { - def live(instrumentName : String) = OpenTelemetry.metrics(instrumentName) + def live(instrumentName: String) = OpenTelemetry.metrics(instrumentName) } trait Traces { - this: ZIOtelBase => + this: ZIOtelBase => - override def tracerProvider: URIO[Scope, Option[SdkTracerProvider]] = TracerProvider.grpc(resourceName) + override def tracerProvider: URIO[Scope, Option[SdkTracerProvider]] = TracerProvider.grpc(resourceName) - def otel4zTracing(instrumentationScopeName: String, + def otel4zTracing( + instrumentationScopeName: String, instrumentationVersion: Option[String] = None, schemaUrl: Option[String] = None, logAnnotated: Boolean = false - ) = OpenTelemetry.tracing( - instrumentationScopeName = instrumentationScopeName, - instrumentationVersion = instrumentationVersion, - schemaUrl = schemaUrl, - logAnnotated = logAnnotated - ) + ) = OpenTelemetry.tracing( + instrumentationScopeName = instrumentationScopeName, + instrumentationVersion = instrumentationVersion, + schemaUrl = schemaUrl, + logAnnotated = logAnnotated + ) } object Traces { - def live(instrumentName : String) = OpenTelemetry.tracing(instrumentName) + def live(instrumentName: String) = OpenTelemetry.tracing(instrumentName) -} \ No newline at end of file +} From cc5dc252ca83eafcd5a2154cbbf0e78d7a3b096a Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Sat, 16 May 2026 16:28:26 +0200 Subject: [PATCH 03/17] Renaming --- .../observability/ZIOpenTelemetryExample.skip | 6 +- .../server/o11y/otel4z/ZIOpenTelemetry.scala | 1 - .../server/o11y/otel4z/ZIOpenTelemetry.scala | 5 +- .../tapir/server/o11y/otel4z/Providers.scala | 70 +++++++++++--- .../server/o11y/otel4z/TracerProvider.scala | 3 + .../o11y/otel4z/ZIOpenTelemetryBase.scala | 92 +++++++++++++++++-- ...Layer.scala => ZIOpenTelemetryLayer.scala} | 44 ++++----- ...ing.scala => ZIOpenTelemetryTracing.scala} | 24 +++-- ...ala => ZIOpenTelemetryTracingConfig.scala} | 18 ++-- .../tapir/server/o11y/otel4z/ZIOtelBase.scala | 84 ----------------- .../o11y/otel4z/ZIOtelTracingTest.scala | 4 +- 11 files changed, 187 insertions(+), 164 deletions(-) rename observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/{ZIOtelLayer.scala => ZIOpenTelemetryLayer.scala} (68%) rename observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/{ZIOtelTracing.scala => ZIOpenTelemetryTracing.scala} (93%) rename observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/{ZIOtelTracingConfig.scala => ZIOpenTelemetryTracingConfig.scala} (92%) delete mode 100644 observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelBase.scala diff --git a/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip b/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip index 824dc0adee..f580b5ebec 100644 --- a/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip +++ b/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip @@ -96,7 +96,7 @@ object ZIOpenTelemetryExample extends ZIOApp with ZIOpenTelemetry("zio-observabi // which is used to create spans for incoming requests and other operations. otel4zTracing(resourceName), - ZIOpenTelemetry.runtimeTelemetry + otel4zRuntimeTelemetry ) @@ -111,12 +111,12 @@ object ZIOpenTelemetryExample extends ZIOApp with ZIOpenTelemetry("zio-observabi tracing: Tracing ): ZioHttpServerOptions[Any] = ZioHttpServerOptions.customiseInterceptors .prependInterceptor( - ZIOtelTracing(tracing) + ZIOpenTelemetryTracing(tracing) ) .appendInterceptor( CORSInterceptor.default ) - .appendInterceptor(metricsInterceptor) + .appendInterceptor(otel4zMetricsInterceptor()) .serverLog( ZioHttpServerOptions.defaultServerLog[Any] ) diff --git a/observability/otel4z/src/main/scala-2/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala b/observability/otel4z/src/main/scala-2/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala index 788b24757c..827c27a602 100644 --- a/observability/otel4z/src/main/scala-2/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala +++ b/observability/otel4z/src/main/scala-2/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala @@ -13,4 +13,3 @@ trait ZIOpenTelemetryFull extends ZIOtelBase with Metrics with Traces { this: ZIOApp => } -object ZIOpenTelemetry extends ZIOpenTelemetryBase \ No newline at end of file diff --git a/observability/otel4z/src/main/scala-3/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala b/observability/otel4z/src/main/scala-3/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala index 8ba79bb206..fb375a82c3 100644 --- a/observability/otel4z/src/main/scala-3/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala +++ b/observability/otel4z/src/main/scala-3/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala @@ -5,13 +5,12 @@ import zio.ZIOApp /** ZIOpenTelemetry is a trait that provides a ZIO layer for OpenTelemetry. * @param name */ -trait ZIOpenTelemetry(val resourceName: String) extends ZIOtelBase { +trait ZIOpenTelemetry(val resourceName: String) extends ZIOpentelemetryBase { this: ZIOApp => } -trait ZIOpenTelemetryFull(val resourceName: String) extends ZIOtelBase with Metrics with Traces { +trait ZIOpenTelemetryFull(val resourceName: String) extends ZIOpentelemetryBase with Metrics with Traces { this: ZIOApp => } -object ZIOpenTelemetry extends ZIOpenTelemetryBase \ No newline at end of file diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/Providers.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/Providers.scala index 227579d1f4..5337a3c56f 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/Providers.scala +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/Providers.scala @@ -9,23 +9,65 @@ import io.opentelemetry.api import sttp.tapir.server.metrics.opentelemetry.OpenTelemetryMetrics import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor import io.opentelemetry.sdk.logs.SdkLoggerProvider +import io.opentelemetry.instrumentation.runtimetelemetry.RuntimeTelemetry +import zio.telemetry.opentelemetry.context.ContextStorage +/** + * Logging, Metrics and Tracing providers for OpenTelemetry. + */ trait Logging { - this: ZIOtelBase => + this: ZIOpentelemetryBase => + /** + * Provides a logger provider for OpenTelemetry, which logs in OTLP Json format to stdout. + */ override def logProvider: URIO[Scope, Option[SdkLoggerProvider]] = LoggerProvider.grpc(resourceName) - def otel4zLogging(instrumentationScopeName: String, logLevel: LogLevel = LogLevel.Info) = OpenTelemetry.logging( + /** + * A OpenTelemetry logging layer, with configurable instrumentation scope name and log level. + * + * @param instrumentationScopeName + * @param logLevel + * @return + */ + def otel4zLogging(instrumentationScopeName: String, logLevel: LogLevel = LogLevel.Info): URLayer[api.OpenTelemetry with ContextStorage, Unit] = OpenTelemetry.logging( instrumentationScopeName = instrumentationScopeName, logLevel = logLevel ) } +/** + * Metrics provider for OpenTelemetry. + */ trait Metrics { - this: ZIOtelBase => + this: ZIOpentelemetryBase => + /** + * Provides a meter provider for OpenTelemetry, which exports metrics in OTLP Json format to stdout. + */ override def meterProvider: URIO[Scope, Option[SdkMeterProvider]] = MeterProvider.grpc(resourceName) + /** + * A OpenTelemetry runtime metrics layer. + * + * @return + */ + def otel4zRuntimeTelemetry = ZLayer.fromZIO( + for { + openTelemetry <- ZIO.service[io.opentelemetry.api.OpenTelemetry] + _ <- ZIO.fromAutoCloseable(ZIO.succeed(RuntimeTelemetry.create(openTelemetry))) + } yield () + ) + + /** + * A OpenTelemetry metrics layer, with configurable instrumentation scope name, version and schema url. + * + * @param instrumentationScopeName + * @param instrumentationVersion + * @param schemaUrl + * @param logAnnotated + * @return + */ def otel4zMetrics( instrumentationScopeName: String, instrumentationVersion: Option[String] = None, @@ -38,8 +80,17 @@ trait Metrics { logAnnotated = logAnnotated ) - def metricsInterceptor(implicit otel: api.OpenTelemetry): MetricsRequestInterceptor[Task] = { - val meter: api.metrics.Meter = otel.meterBuilder("tapir").build() + /** + * A OpenTelemetry metrics interceptor for tapir, with configurable instrumentation scope name. + * + * It uses the OpenTelemetry instance from the environment, which is provided by the [[ZIOpenTelemetry]] trait bootstrap layer. + * + * @param instrumentationScopeName + * @param otel + * @return + */ + def otel4zMetricsInterceptor(instrumentationScopeName: String = "tapir")(implicit otel: api.OpenTelemetry): MetricsRequestInterceptor[Task] = { + val meter: api.metrics.Meter = otel.meterBuilder(instrumentationScopeName).build() val metrics = OpenTelemetryMetrics.default[Task](meter) @@ -48,12 +99,9 @@ trait Metrics { } -object Metrics { - def live(instrumentName: String) = OpenTelemetry.metrics(instrumentName) -} trait Traces { - this: ZIOtelBase => + this: ZIOpentelemetryBase => override def tracerProvider: URIO[Scope, Option[SdkTracerProvider]] = TracerProvider.grpc(resourceName) @@ -70,7 +118,3 @@ trait Traces { ) } -object Traces { - def live(instrumentName: String) = OpenTelemetry.tracing(instrumentName) - -} diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/TracerProvider.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/TracerProvider.scala index 1e5c35ea87..cc6da48feb 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/TracerProvider.scala +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/TracerProvider.scala @@ -10,6 +10,9 @@ import zio._ import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter import io.opentelemetry.exporter.logging.otlp.OtlpJsonLoggingSpanExporter +/** + * Provides a tracer provider for OpenTelemetry. + */ object TracerProvider extends OtlpEndpoint { /** Prints to stdout in OTLP Json format diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryBase.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryBase.scala index e95e3e1cc1..f8d96fb8b8 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryBase.scala +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryBase.scala @@ -1,19 +1,91 @@ package sttp.tapir.server.o11y.otel4z +import io.opentelemetry.api import zio._ -import io.opentelemetry.instrumentation.runtimetelemetry.RuntimeTelemetry +import zio.logging.backend.SLF4J +import zio.telemetry.opentelemetry.context.ContextStorage -trait ZIOpenTelemetryBase { +import zio.telemetry.opentelemetry.OpenTelemetry +import io.opentelemetry.sdk.trace.SdkTracerProvider +import io.opentelemetry.sdk.metrics.SdkMeterProvider +import io.opentelemetry.sdk.logs.SdkLoggerProvider + + +/** ZIOTelBase is a trait that provides a ZIO layer for OpenTelemetry as bootstrap. + * + * By default, it uses the OTEL_EXPORTER_OTLP_ENDPOINT environment variable to configure the OpenTelemetry exporter. + * + * - Uses SLF4J for logging to stdout. + * - Logs, Metrics, Traces are sent to the OpenTelemetry collector through gRPC. + */ +protected trait ZIOpentelemetryBase { + this: ZIOApp => + + /** The name of the resource, advertised to the OpenTelemetry collector. */ + def resourceName: String + + /** Whether to enable ZIO internal metrics. + * + * This relies on [[ZioMetrics]] which must be provided **early** by the bootstrap layer. + * + * + * Default is true. + */ + def withZIOMetrics: Boolean = true + + + /** The environment for the ZIOpenTelemetry trait. + * + * This is the environment that will be used to run the ZIO application, hence provided by bootstrap. + * + * It includes: + * - the OpenTelemetry instance. + * - the ContextStorage instance. + */ + override type Environment = api.OpenTelemetry with ContextStorage + + /** The tag for the ZIOpenTelemetry trait. */ + def environmentTag: Tag[Environment] = + Tag[Environment] - def runtimeTelemetry = ZLayer.fromZIO( - for { - openTelemetry <- ZIO.service[io.opentelemetry.api.OpenTelemetry] - _ <- ZIO.fromAutoCloseable(ZIO.succeed(RuntimeTelemetry.create(openTelemetry))) - } yield () - ) - - + /** + * The console log layer for the ZIOpenTelemetry trait. + * + * Default implementation uses SLF4J for logging to stdout. + */ + def consoleLogLayer: ZLayer[Any, Nothing, Unit] = Runtime.removeDefaultLoggers >>> SLF4J.slf4j + /** + * The OpenTelemetry providers for the ZIOpenTelemetry trait. + * + * @return + */ + def logProvider: URIO[Scope, Option[SdkLoggerProvider]] = ZIO.none + + def meterProvider: URIO[Scope, Option[SdkMeterProvider]] = ZIO.none + + def tracerProvider: URIO[Scope, Option[SdkTracerProvider]] = ZIO.none + + + final def otelProviders: URIO[Scope, OtelProviders] = for { + logger <- logProvider + meter <- meterProvider + tracer <- tracerProvider + } yield OtelProviders(tracer, meter, logger) + + /** The bootstrap layer for the ZIOpenTelemetry trait. + * + * This is the layer that will be used to bootstrap the ZIO application. It includes the OpenTelemetry layer, the Tracing layer, and the + * Meter layer. + */ + override val bootstrap: ZLayer[ZIOAppArgs, Any, Environment] = + consoleLogLayer >>> + OpenTelemetry.contextZIO >+> (ZLayer.scoped(otelProviders) >>> + ZIOpenTelemetryLayer + .live(withZIOMetrics)) + + + } diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelLayer.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryLayer.scala similarity index 68% rename from observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelLayer.scala rename to observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryLayer.scala index 6fc3f37ace..e653f92413 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelLayer.scala +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryLayer.scala @@ -9,11 +9,9 @@ import io.opentelemetry.sdk.trace.SdkTracerProvider import zio.telemetry.opentelemetry.OpenTelemetry import zio.telemetry.opentelemetry.context.ContextStorage - -/** - * OtelProviders is a case class that holds the OpenTelemetry providers for tracing, metrics and logging. - * - * It is used to build the OpenTelemetry +/** OtelProviders is a case class that holds the OpenTelemetry providers for tracing, metrics and logging. + * + * It is used to build the OpenTelemetry * * @param tracerProvider * @param meterProvider @@ -22,28 +20,24 @@ import zio.telemetry.opentelemetry.context.ContextStorage case class OtelProviders( tracerProvider: Option[SdkTracerProvider], meterProvider: Option[SdkMeterProvider], - loggerProvider: Option[SdkLoggerProvider], -){ - + loggerProvider: Option[SdkLoggerProvider] +) { - def build(): OpenTelemetrySdk = { - val builder =OpenTelemetrySdk - .builder() + def build(): OpenTelemetrySdk = { + val builder = OpenTelemetrySdk + .builder() tracerProvider.foreach(builder.setTracerProvider) meterProvider.foreach(builder.setMeterProvider) loggerProvider.foreach(builder.setLoggerProvider) builder.build() } - def withRuntimeTelemetry: Boolean = meterProvider.isDefined } -object ZIOtelLayer { +object ZIOpenTelemetryLayer { - - /** - * The OpenTelemetry layer for the ZIOpenTelemetry trait. - * + /** The OpenTelemetry layer for the ZIOpenTelemetry trait. + * * This is a separate method pulled by the bootstrap layer, as it is used to provide the OpenTelemetry layer to the server options, which * are provided by the ZIO application itself. This allows the OpenTelemetry layer to be used * @@ -56,15 +50,13 @@ object ZIOtelLayer { else otel private def otel = ZLayer.scoped[OtelProviders]( - for { - otelProviders <- ZIO.service[OtelProviders] - openTelemetry <- ZIO.fromAutoCloseable( - ZIO.succeed(otelProviders.build()) - ) - - } yield openTelemetry - ) + for { + otelProviders <- ZIO.service[OtelProviders] + openTelemetry <- ZIO.fromAutoCloseable( + ZIO.succeed(otelProviders.build()) + ) + } yield openTelemetry + ) - } diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracing.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryTracing.scala similarity index 93% rename from observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracing.scala rename to observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryTracing.scala index 24dc5ef1d5..d1c5a48f89 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracing.scala +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryTracing.scala @@ -19,6 +19,7 @@ import io.opentelemetry.api.trace.SpanKind import io.opentelemetry.api.trace.StatusCode import io.opentelemetry.api.common.Attributes +import zio.telemetry.opentelemetry.context.IncomingContextCarrier /** Interceptor which traces requests using otel4s. * @@ -45,13 +46,16 @@ import io.opentelemetry.api.common.Attributes * }}} */ -class ZIOtelTracing( +class ZIOpenTelemetryTracing( tracing: Tracing, - config: ZIOtelTracingConfig + config: ZIOpenTelemetryTracingConfig ) extends RequestInterceptor[Task] { import config._ + + def newCarrier() = IncomingContextCarrier.default() + override def apply[R, B]( responder: Responder[Task, B], requestHandler: EndpointInterceptor[Task] => RequestHandler[Task, R, B] @@ -64,7 +68,7 @@ class ZIOtelTracing( )(implicit monad: MonadError[Task]): Task[RequestResult[B]] = tracing .extractSpanUnsafe( config.propagator, - config.carrier, + newCarrier(), request.showShort, spanKind = SpanKind.SERVER, attributes = config.requestAttributes(request) @@ -182,7 +186,7 @@ class ZIOtelTracing( } } -object ZIOtelTracing { +object ZIOpenTelemetryTracing { /** Create a new ZIOpenTelemetryTracing interceptor with the provided Tracing and default configuration. * @@ -191,19 +195,19 @@ object ZIOtelTracing { */ def apply( tracing: Tracing - ): ZIOtelTracing = - new ZIOtelTracing( + ): ZIOpenTelemetryTracing = + new ZIOpenTelemetryTracing( tracing, - ZIOtelTracingConfig() + ZIOpenTelemetryTracingConfig() ) /** Create a new ZIOpenTelemetryTracing interceptor with the provided Tracing and configuration. */ def apply( tracing: Tracing, - config: ZIOtelTracingConfig - ): ZIOtelTracing = - new ZIOtelTracing( + config: ZIOpenTelemetryTracingConfig + ): ZIOpenTelemetryTracing = + new ZIOpenTelemetryTracing( tracing, config ) diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingConfig.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryTracingConfig.scala similarity index 92% rename from observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingConfig.scala rename to observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryTracingConfig.scala index 1e02da2a6a..b892f7ec84 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingConfig.scala +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryTracingConfig.scala @@ -13,7 +13,7 @@ import io.opentelemetry.semconv.ServerAttributes import io.opentelemetry.semconv.ErrorAttributes import scala.annotation.nowarn import zio.telemetry.opentelemetry.tracing.propagation.TraceContextPropagator -import zio.telemetry.opentelemetry.context.IncomingContextCarrier + /** Configuration for OpenTelemetry Otel4z tracing of server requests, used by [[ZIOpenTelemetry]]. Use the apply method to override only * some of the configuration options, while using the defaults for the rest. @@ -37,11 +37,9 @@ import zio.telemetry.opentelemetry.context.IncomingContextCarrier * Calculates additional attributes of the span, given an error that occurred while processing the request (an exception); although * usually, exceptions are translated into 5xx responses earlier in the interceptor chain. */ -case class ZIOtelTracingConfig( +case class ZIOpenTelemetryTracingConfig( propagator: TraceContextPropagator, - carrier: IncomingContextCarrier[ - scala.collection.mutable.Map[String, String] - ], + spanName: ServerRequest => String, requestAttributes: ServerRequest => Attributes, @@ -53,12 +51,9 @@ case class ZIOtelTracingConfig( errorAttributes: Either[StatusCode, Throwable] => Attributes ) -object ZIOtelTracingConfig { +object ZIOpenTelemetryTracingConfig { def apply( propagator: TraceContextPropagator = TraceContextPropagator.default, - carrier: IncomingContextCarrier[ - scala.collection.mutable.Map[String, String] - ] = IncomingContextCarrier.default(), spanName: ServerRequest => String = Defaults.spanName, requestAttributes: ServerRequest => Attributes = Defaults.requestAttributes, spanNameFromEndpointAndAttributes: (ServerRequest, AnyEndpoint) => ( @@ -67,10 +62,9 @@ object ZIOtelTracingConfig { ) = Defaults.spanNameFromEndpointAndAttributes, responseAttributes: (ServerRequest, ServerResponse[?]) => Attributes = Defaults.responseAttributes, errorAttributes: Either[StatusCode, Throwable] => Attributes = Defaults.errorAttributes - ): ZIOtelTracingConfig = - new ZIOtelTracingConfig( + ): ZIOpenTelemetryTracingConfig = + new ZIOpenTelemetryTracingConfig( propagator, - carrier, spanName, requestAttributes, spanNameFromEndpointAndAttributes, diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelBase.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelBase.scala deleted file mode 100644 index b1c974126c..0000000000 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOtelBase.scala +++ /dev/null @@ -1,84 +0,0 @@ -package sttp.tapir.server.o11y.otel4z - -import io.opentelemetry.api -import zio._ -import zio.logging.backend.SLF4J -import zio.telemetry.opentelemetry.context.ContextStorage - -import zio.telemetry.opentelemetry.OpenTelemetry - -import io.opentelemetry.sdk.trace.SdkTracerProvider -import io.opentelemetry.sdk.metrics.SdkMeterProvider -import io.opentelemetry.sdk.logs.SdkLoggerProvider - - -/** ZIOTelBase is a trait that provides a ZIO layer for OpenTelemetry as bootstrap. - * - * By default, it uses the OTEL_EXPORTER_OTLP_ENDPOINT environment variable to configure the OpenTelemetry exporter. - * - * - Uses SLF4J for logging to stdout. - * - Logs, Metrics, Traces are sent to the OpenTelemetry collector through gRPC. - */ -protected trait ZIOtelBase { - this: ZIOApp => - - /** The name of the resource, advertised to the OpenTelemetry collector. */ - def resourceName: String - - def withZIOMetrics: Boolean = true - - - /** The environment for the ZIOpenTelemetry trait. - * - * This is the environment that will be used to run the ZIO application, hence provided by bootstrap. - * - * It includes: - * - the OpenTelemetry instance. - * - the ContextStorage instance. - */ - override type Environment = api.OpenTelemetry with ContextStorage - - /** The tag for the ZIOpenTelemetry trait. */ - def environmentTag: Tag[Environment] = - Tag[Environment] - - - /** - * The console log layer for the ZIOpenTelemetry trait. - * - * Default implementation uses SLF4J for logging to stdout. - */ - def consoleLogLayer: ZLayer[Any, Nothing, Unit] = Runtime.removeDefaultLoggers >>> SLF4J.slf4j - - /** - * The OpenTelemetry providers for the ZIOpenTelemetry trait. - * - * @return - */ - def logProvider: URIO[Scope, Option[SdkLoggerProvider]] = ZIO.none - - def meterProvider: URIO[Scope, Option[SdkMeterProvider]] = ZIO.none - - def tracerProvider: URIO[Scope, Option[SdkTracerProvider]] = ZIO.none - - - final def otelProviders: URIO[Scope, OtelProviders] = for { - logger <- logProvider - meter <- meterProvider - tracer <- tracerProvider - } yield OtelProviders(tracer, meter, logger) - - /** The bootstrap layer for the ZIOpenTelemetry trait. - * - * This is the layer that will be used to bootstrap the ZIO application. It includes the OpenTelemetry layer, the Tracing layer, and the - * Meter layer. - */ - override val bootstrap: ZLayer[ZIOAppArgs, Any, Environment] = - consoleLogLayer >>> - OpenTelemetry.contextZIO >+> (ZLayer.scoped(otelProviders) >>> - ZIOtelLayer - .live(withZIOMetrics)) - - - -} diff --git a/observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala b/observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala index 49cfffad6d..514562c55e 100644 --- a/observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala +++ b/observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala @@ -11,7 +11,7 @@ import sttp.tapir.TestUtil.serverRequestFromUri import sttp.tapir.capabilities.NoStreams import sttp.tapir.model.ServerRequest import sttp.tapir.server.interpreter._ -import sttp.tapir.server.o11y.otel4z.ZIOtelTracing +import sttp.tapir.server.o11y.otel4z.ZIOpenTelemetryTracing import sttp.tapir.server.TestUtil.StringToResponseBody import io.opentelemetry.api.trace.Tracer @@ -71,7 +71,7 @@ object ZIOtelTracingTest extends ZIOSpecDefault { _ => List(endpointa), ZIOTestRequestBody, StringToResponseBody, - List(ZIOtelTracing(tracing)), + List(ZIOpenTelemetryTracing(tracing)), _ => ZIO.succeed(()) ) _ <- interpreter(request) From 94ddded1da77b1c47f4c20b3ff13a97897c5005c Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Sat, 16 May 2026 18:28:19 +0200 Subject: [PATCH 04/17] Remove unnecessary Scope.default reference in ZIOpenTelemetryExample and clarify comments for OpenTelemetry Runtime Metrics service. --- build.sbt | 2 - ...mple.skip => ZIOpenTelemetryExample.scala} | 30 ++++- .../server/o11y/otel4z/ZIOpenTelemetry.scala | 9 +- .../server/o11y/otel4z/ZIOpenTelemetry.scala | 5 +- .../server/o11y/otel4z/LoggerProvider.scala | 74 ++++++----- .../server/o11y/otel4z/MeterProvider.scala | 104 +++++++--------- .../server/o11y/otel4z/OtlpEndpoint.scala | 7 +- .../tapir/server/o11y/otel4z/Providers.scala | 56 +++------ .../server/o11y/otel4z/TracerProvider.scala | 115 ++++++------------ .../o11y/otel4z/ZIOpenTelemetryBase.scala | 73 ++++++----- .../o11y/otel4z/ZIOpenTelemetryTracing.scala | 1 - .../otel4z/ZIOpenTelemetryTracingConfig.scala | 11 +- .../o11y/otel4z/ZIOtelTracingTest.scala | 16 ++- project/Versions.scala | 1 - 14 files changed, 229 insertions(+), 275 deletions(-) rename examples/src/main/scala/sttp/tapir/examples/observability/{ZIOpenTelemetryExample.skip => ZIOpenTelemetryExample.scala} (84%) diff --git a/build.sbt b/build.sbt index 478dfdd8ee..a0b39dfab3 100644 --- a/build.sbt +++ b/build.sbt @@ -1195,10 +1195,8 @@ lazy val otel4z: ProjectMatrix = (projectMatrix in file("observability/otel4z")) "io.opentelemetry" % "opentelemetry-sdk" % Versions.openTelemetry, "io.opentelemetry" % "opentelemetry-exporter-otlp" % Versions.openTelemetry, "io.opentelemetry" % "opentelemetry-exporter-logging-otlp" % Versions.openTelemetry, - "io.opentelemetry.instrumentation" % "opentelemetry-runtime-telemetry-java17" % Versions.openTelemetryRuntime, "dev.zio" %% "zio-test" % Versions.zio % Test, "dev.zio" %% "zio-test-sbt" % Versions.zio % Test, - "io.opentelemetry" % "opentelemetry-sdk-testing" % Versions.openTelemetry % Test ) ) diff --git a/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip b/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.scala similarity index 84% rename from examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip rename to examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.scala index f580b5ebec..d1facf6bbe 100644 --- a/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip +++ b/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.scala @@ -22,6 +22,7 @@ import zio.http._ import zio.telemetry.opentelemetry.metrics.Meter import zio.telemetry.opentelemetry.tracing.Tracing import sttp.tapir.server.interceptor.cors.CORSInterceptor +import io.opentelemetry.api.common.Attributes @@ -34,7 +35,7 @@ import sttp.tapir.server.interceptor.cors.CORSInterceptor * To effectively produce traces, you need to set the OTEL_EXPORTER_OTLP_ENDPOINT environment variable to the address of your * OpenTelemetry. */ -object ZIOpenTelemetryExample extends ZIOApp with ZIOpenTelemetry("zio-observability-example") with Logging with Metrics with Traces { +object ZIOpenTelemetryExample extends ZIOApp with ZIOpenTelemetry("zio-observability-example", Some("1.0.0"), Some("dev")) with Logging with Metrics with Traces { /** The server options for the ZIOOpenTelemetry trait. * @@ -44,6 +45,8 @@ object ZIOpenTelemetryExample extends ZIOApp with ZIOpenTelemetry("zio-observabi */ + override def extraAttributes: Attributes = Attributes.builder().put("stack", "zio").build() + // The main program - start the server on port 8080 val program = for @@ -80,7 +83,7 @@ object ZIOpenTelemetryExample extends ZIOApp with ZIOpenTelemetry("zio-observabi */ override def run = program.provideSome[Environment]( - Scope.default, + Server.default, // This layers provides sample custom metric, which will be visible in the OpenTelemetry collector and can be used to verify that the metrics are working. @@ -96,11 +99,15 @@ object ZIOpenTelemetryExample extends ZIOApp with ZIOpenTelemetry("zio-observabi // which is used to create spans for incoming requests and other operations. otel4zTracing(resourceName), - otel4zRuntimeTelemetry + // This layer provides the OpenTelemetry Runtime Metrics service, which is used to expose ZIO runtime metrics. + // Scope.default, + // RuntimeMetrics.otel4zRuntimeTelemetry ) + + /** The server options for the ZIOpenTelemetry trait. * * This is the server options that will be used to run the ZIO application, hence provided by bootstrap. It includes the OpenTelemetry @@ -159,6 +166,23 @@ object TickCounter { ) } +/* + +To provide runtime metrics, you can use the OpenTelemetry Runtime Telemetry module, which is available as a separate dependency. +It provides a RuntimeTelemetry class that can be instantiated with an OpenTelemetry instance and will automatically collect and export runtime metrics. + +"io.opentelemetry.instrumentation" % "opentelemetry-runtime-telemetry-java17" % + +object RuntimeMetrics { + def otel4zRuntimeTelemetry = ZLayer.fromZIO( + for { + openTelemetry <- ZIO.service[io.opentelemetry.api.OpenTelemetry] + _ <- ZIO.fromAutoCloseable(ZIO.succeed(RuntimeTelemetry.create(openTelemetry))) + } yield () + ) +} +*/ + class ZIOHttpApi(using tracing: Tracing) { val helloEndpoint: ServerEndpoint[Any, Task] = sttp.tapir.endpoint.get diff --git a/observability/otel4z/src/main/scala-2/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala b/observability/otel4z/src/main/scala-2/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala index 827c27a602..82535a1280 100644 --- a/observability/otel4z/src/main/scala-2/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala +++ b/observability/otel4z/src/main/scala-2/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala @@ -5,11 +5,12 @@ import zio.ZIOApp /** ZIOpenTelemetry is a trait that provides a ZIO layer for OpenTelemetry. * @param name */ -trait ZIOpenTelemetry extends ZIOtelBase { +trait ZIOpenTelemetry extends ZIOpentelemetryBase { this: ZIOApp => -} -trait ZIOpenTelemetryFull extends ZIOtelBase with Metrics with Traces { - this: ZIOApp => + def version: Option[String] = None + + def environment: Option[String] = None } + diff --git a/observability/otel4z/src/main/scala-3/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala b/observability/otel4z/src/main/scala-3/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala index fb375a82c3..f56b35ba06 100644 --- a/observability/otel4z/src/main/scala-3/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala +++ b/observability/otel4z/src/main/scala-3/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala @@ -5,12 +5,9 @@ import zio.ZIOApp /** ZIOpenTelemetry is a trait that provides a ZIO layer for OpenTelemetry. * @param name */ -trait ZIOpenTelemetry(val resourceName: String) extends ZIOpentelemetryBase { +trait ZIOpenTelemetry(val resourceName: String, val version: Option[String]=None, val environment: Option[String]=None) extends ZIOpentelemetryBase { this: ZIOApp => } -trait ZIOpenTelemetryFull(val resourceName: String) extends ZIOpentelemetryBase with Metrics with Traces { - this: ZIOApp => -} diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/LoggerProvider.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/LoggerProvider.scala index 3f07df6b37..366678294e 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/LoggerProvider.scala +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/LoggerProvider.scala @@ -7,44 +7,54 @@ import io.opentelemetry.sdk.logs.`export`.SimpleLogRecordProcessor import io.opentelemetry.sdk.resources.Resource import zio._ -import io.opentelemetry.semconv.ServiceAttributes import io.opentelemetry.exporter.otlp.logs.OtlpGrpcLogRecordExporter -object LoggerProvider extends OtlpEndpoint { +/** Provides a logger provider for OpenTelemetry. + */ +object LoggerProvider { - /** gRPC exporter that sends logs to the endpoint specified in the environment variable. - * - * If the environment variable is not set, will fallback OTEL_EXPORTER_OTLP_ENDPOINT env var or "http://localhost:4317". + /** Provides a logger provider for OpenTelemetry, which logs in OTLP Json format as gRPC if either of the following environment variables + * is set: + * - `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` + * - `OTEL_EXPORTER_OTLP_ENDPOINT` */ - def grpc(resourceName: String): URIO[Scope, Option[SdkLoggerProvider]] = - for { - logRecordExporter <- - ZIO.fromAutoCloseable( - ZIO.succeed( - OtlpGrpcLogRecordExporter - .builder() - .setEndpoint(getEndpoint("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT")) - .build() + def grpc(attributes: Attributes): URIO[Scope, Option[SdkLoggerProvider]] = OtlpEndpoint("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT") match { + case None => + ZIO.logInfo( + "No OTLP logs endpoint configured, skipping OpenTelemetry logging setup. To enable it, set either OTEL_EXPORTER_OTLP_LOGS_ENDPOINT or OTEL_EXPORTER_OTLP_ENDPOINT environment variable." + ) *> ZIO.succeed(None) + + case Some(endpoint) => + for { + _ <- ZIO.logInfo(s"Configuring OpenTelemetry logging to $endpoint") + logRecordExporter <- + ZIO.fromAutoCloseable( + ZIO.succeed( + OtlpGrpcLogRecordExporter + .builder() + .setEndpoint(endpoint) + .build() + ) + ) + logRecordProcessor <- + ZIO.fromAutoCloseable( + ZIO.succeed(SimpleLogRecordProcessor.create(logRecordExporter)) ) - ) - logRecordProcessor <- - ZIO.fromAutoCloseable( - ZIO.succeed(SimpleLogRecordProcessor.create(logRecordExporter)) - ) - loggerProvider <- - ZIO.fromAutoCloseable( - ZIO.succeed( - SdkLoggerProvider - .builder() - .setResource( - Resource.create( - Attributes.of(ServiceAttributes.SERVICE_NAME, resourceName) + loggerProvider <- + ZIO.fromAutoCloseable( + ZIO.succeed( + SdkLoggerProvider + .builder() + .setResource( + Resource.create( + attributes + ) ) - ) - .addLogRecordProcessor(logRecordProcessor) - .build() + .addLogRecordProcessor(logRecordProcessor) + .build() + ) ) - ) - } yield Some(loggerProvider) + } yield Some(loggerProvider) + } } diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/MeterProvider.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/MeterProvider.scala index d3a14a7759..ca2b284ccf 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/MeterProvider.scala +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/MeterProvider.scala @@ -5,76 +5,54 @@ import io.opentelemetry.sdk.metrics.SdkMeterProvider import io.opentelemetry.sdk.metrics.`export`.PeriodicMetricReader import io.opentelemetry.sdk.resources.Resource import io.opentelemetry.api.common.Attributes -import io.opentelemetry.semconv.ServiceAttributes -import io.opentelemetry.exporter.logging.otlp.OtlpJsonLoggingMetricExporter + import io.opentelemetry.exporter.otlp.metrics.OtlpGrpcMetricExporter -object MeterProvider extends OtlpEndpoint { +/** Provides a meter provider for OpenTelemetry. + */ +object MeterProvider { - /** Prints to stdout in OTLP Json format + /** Provides a meter provider for OpenTelemetry, which logs in OTLP Json format as gRPC if either of the following environment variables + * is set: + * - `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` + * - `OTEL_EXPORTER_OTLP_ENDPOINT` */ - def stdout(resourceName: String): RIO[Scope, SdkMeterProvider] = - for { - metricExporter <- ZIO.fromAutoCloseable( - ZIO.succeed(OtlpJsonLoggingMetricExporter.create()) - ) - metricReader <- - ZIO.fromAutoCloseable( - ZIO.succeed( - PeriodicMetricReader - .builder(metricExporter) - .setInterval(5.second) - .build() - ) - ) - meterProvider <- - ZIO.fromAutoCloseable( - ZIO.succeed( - SdkMeterProvider - .builder() - .registerMetricReader(metricReader) - .setResource( - Resource.create( - Attributes.of(ServiceAttributes.SERVICE_NAME, resourceName) - ) - ) - .build() - ) - ) - } yield meterProvider + def grpc(attributes: Attributes): URIO[Scope, Option[SdkMeterProvider]] = OtlpEndpoint("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT") match { + case None => + ZIO.logInfo( + "No OTLP metrics endpoint configured, skipping OpenTelemetry metrics setup. To enable it, set either OTEL_EXPORTER_OTLP_METRICS_ENDPOINT or OTEL_EXPORTER_OTLP_ENDPOINT environment variable." + ) *> ZIO.succeed(None) - /** gRPC exporter that sends metrics to the endpoint specified in the environment variable. - * - * If the environment variable is not set, will fallback OTEL_EXPORTER_OTLP_ENDPOINT env var or "http://localhost:4317". - */ - def grpc(resourceName: String): URIO[Scope, Option[SdkMeterProvider]] = - for { - metricExporter <- ZIO.fromAutoCloseable( - ZIO.succeed(OtlpGrpcMetricExporter.builder().setEndpoint(getEndpoint("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT")).build()) - ) - metricReader <- - ZIO.fromAutoCloseable( - ZIO.succeed( - PeriodicMetricReader - .builder(metricExporter) - .setInterval(5.second) - .build() - ) + case Some(endpoint) => + for { + _ <- ZIO.logInfo(s"Configuring OpenTelemetry metrics to $endpoint") + metricExporter <- ZIO.fromAutoCloseable( + ZIO.succeed(OtlpGrpcMetricExporter.builder().setEndpoint(endpoint).build()) ) - meterProvider <- - ZIO.fromAutoCloseable( - ZIO.succeed( - SdkMeterProvider - .builder() - .registerMetricReader(metricReader) - .setResource( - Resource.create( - Attributes.of(ServiceAttributes.SERVICE_NAME, resourceName) + metricReader <- + ZIO.fromAutoCloseable( + ZIO.succeed( + PeriodicMetricReader + .builder(metricExporter) + .setInterval(5.second) + .build() + ) + ) + meterProvider <- + ZIO.fromAutoCloseable( + ZIO.succeed( + SdkMeterProvider + .builder() + .registerMetricReader(metricReader) + .setResource( + Resource.create( + attributes + ) ) - ) - .build() + .build() + ) ) - ) - } yield Some(meterProvider) + } yield Some(meterProvider) + } } diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/OtlpEndpoint.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/OtlpEndpoint.scala index b42c334fcc..8756f54cc7 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/OtlpEndpoint.scala +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/OtlpEndpoint.scala @@ -1,6 +1,6 @@ package sttp.tapir.server.o11y.otel4z -trait OtlpEndpoint { +object OtlpEndpoint { /** OTLP gRPC endpoint to export telemetry data to. * @@ -15,11 +15,8 @@ trait OtlpEndpoint { * @param envVar * @return */ - protected def getEndpoint(envVar: String): String = + def apply(envVar: String): Option[String] = sys.env .get(envVar) .orElse(sys.env.get("OTEL_EXPORTER_OTLP_ENDPOINT")) - .getOrElse( - "http://localhost:4317" - ) } diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/Providers.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/Providers.scala index 5337a3c56f..438c4df304 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/Providers.scala +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/Providers.scala @@ -9,58 +9,43 @@ import io.opentelemetry.api import sttp.tapir.server.metrics.opentelemetry.OpenTelemetryMetrics import sttp.tapir.server.interceptor.metrics.MetricsRequestInterceptor import io.opentelemetry.sdk.logs.SdkLoggerProvider -import io.opentelemetry.instrumentation.runtimetelemetry.RuntimeTelemetry + import zio.telemetry.opentelemetry.context.ContextStorage -/** - * Logging, Metrics and Tracing providers for OpenTelemetry. +/** Logging, Metrics and Tracing providers for OpenTelemetry. */ trait Logging { this: ZIOpentelemetryBase => - /** - * Provides a logger provider for OpenTelemetry, which logs in OTLP Json format to stdout. - */ - override def logProvider: URIO[Scope, Option[SdkLoggerProvider]] = LoggerProvider.grpc(resourceName) + /** Provides a logger provider for OpenTelemetry, which logs in OTLP Json format to stdout. + */ + override def logProvider: URIO[Scope, Option[SdkLoggerProvider]] = LoggerProvider.grpc(attributes) - /** - * A OpenTelemetry logging layer, with configurable instrumentation scope name and log level. + /** A OpenTelemetry logging layer, with configurable instrumentation scope name and log level. * * @param instrumentationScopeName * @param logLevel * @return */ - def otel4zLogging(instrumentationScopeName: String, logLevel: LogLevel = LogLevel.Info): URLayer[api.OpenTelemetry with ContextStorage, Unit] = OpenTelemetry.logging( + def otel4zLogging( + instrumentationScopeName: String, + logLevel: LogLevel = LogLevel.Info + ): URLayer[api.OpenTelemetry with ContextStorage, Unit] = OpenTelemetry.logging( instrumentationScopeName = instrumentationScopeName, logLevel = logLevel ) } -/** - * Metrics provider for OpenTelemetry. +/** Metrics provider for OpenTelemetry. */ trait Metrics { this: ZIOpentelemetryBase => - /** - * Provides a meter provider for OpenTelemetry, which exports metrics in OTLP Json format to stdout. - */ - override def meterProvider: URIO[Scope, Option[SdkMeterProvider]] = MeterProvider.grpc(resourceName) - - /** - * A OpenTelemetry runtime metrics layer. - * - * @return + /** Provides a meter provider for OpenTelemetry, which exports metrics in OTLP Json format to stdout. */ - def otel4zRuntimeTelemetry = ZLayer.fromZIO( - for { - openTelemetry <- ZIO.service[io.opentelemetry.api.OpenTelemetry] - _ <- ZIO.fromAutoCloseable(ZIO.succeed(RuntimeTelemetry.create(openTelemetry))) - } yield () - ) + override def meterProvider: URIO[Scope, Option[SdkMeterProvider]] = MeterProvider.grpc(attributes) - /** - * A OpenTelemetry metrics layer, with configurable instrumentation scope name, version and schema url. + /** A OpenTelemetry metrics layer, with configurable instrumentation scope name, version and schema url. * * @param instrumentationScopeName * @param instrumentationVersion @@ -80,16 +65,17 @@ trait Metrics { logAnnotated = logAnnotated ) - /** - * A OpenTelemetry metrics interceptor for tapir, with configurable instrumentation scope name. - * + /** A OpenTelemetry metrics interceptor for tapir, with configurable instrumentation scope name. + * * It uses the OpenTelemetry instance from the environment, which is provided by the [[ZIOpenTelemetry]] trait bootstrap layer. * * @param instrumentationScopeName * @param otel * @return */ - def otel4zMetricsInterceptor(instrumentationScopeName: String = "tapir")(implicit otel: api.OpenTelemetry): MetricsRequestInterceptor[Task] = { + def otel4zMetricsInterceptor( + instrumentationScopeName: String = "tapir" + )(implicit otel: api.OpenTelemetry): MetricsRequestInterceptor[Task] = { val meter: api.metrics.Meter = otel.meterBuilder(instrumentationScopeName).build() val metrics = OpenTelemetryMetrics.default[Task](meter) @@ -99,11 +85,10 @@ trait Metrics { } - trait Traces { this: ZIOpentelemetryBase => - override def tracerProvider: URIO[Scope, Option[SdkTracerProvider]] = TracerProvider.grpc(resourceName) + override def tracerProvider: URIO[Scope, Option[SdkTracerProvider]] = TracerProvider.grpc(attributes) def otel4zTracing( instrumentationScopeName: String, @@ -117,4 +102,3 @@ trait Traces { logAnnotated = logAnnotated ) } - diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/TracerProvider.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/TracerProvider.scala index cc6da48feb..fe87a66640 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/TracerProvider.scala +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/TracerProvider.scala @@ -5,93 +5,48 @@ import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter import io.opentelemetry.sdk.resources.Resource import io.opentelemetry.sdk.trace.SdkTracerProvider import io.opentelemetry.sdk.trace.`export`.SimpleSpanProcessor -import io.opentelemetry.semconv.ServiceAttributes import zio._ -import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter -import io.opentelemetry.exporter.logging.otlp.OtlpJsonLoggingSpanExporter -/** - * Provides a tracer provider for OpenTelemetry. +/** Provides a tracer provider for OpenTelemetry. */ -object TracerProvider extends OtlpEndpoint { +object TracerProvider { - /** Prints to stdout in OTLP Json format + /** Provides a tracer provider for OpenTelemetry, which logs in OTLP Json format as gRPC if either of the following environment variables + * is set: + * - `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` + * - `OTEL_EXPORTER_OTLP_ENDPOINT` */ - def stdout(resourceName: String): RIO[Scope, SdkTracerProvider] = - for { - spanExporter <- - ZIO.fromAutoCloseable(ZIO.succeed(OtlpJsonLoggingSpanExporter.create())) - spanProcessor <- ZIO.fromAutoCloseable( - ZIO.succeed(SimpleSpanProcessor.create(spanExporter)) - ) - tracerProvider <- - ZIO.fromAutoCloseable( - ZIO.succeed( - SdkTracerProvider - .builder() - .setResource( - Resource.create( - Attributes.of(ServiceAttributes.SERVICE_NAME, resourceName) - ) - ) - .addSpanProcessor(spanProcessor) - .build() - ) - ) - } yield tracerProvider + def grpc(attributes: Attributes): URIO[Scope, Option[SdkTracerProvider]] = + OtlpEndpoint("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") match { - /** gRPC exporter that sends spans to the endpoint specified in the environment variable. - * - * If the environment variable is not set, will fallback OTEL_EXPORTER_OTLP_ENDPOINT env var or "http://localhost:4317". - */ - def grpc(resourceName: String): URIO[Scope, Option[SdkTracerProvider]] = - for { - spanExporter <- ZIO.fromAutoCloseable( - ZIO.succeed(OtlpGrpcSpanExporter.builder().setEndpoint(getEndpoint("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")).build()) - ) - spanProcessor <- ZIO.fromAutoCloseable( - ZIO.succeed(SimpleSpanProcessor.create(spanExporter)) - ) - tracerProvider <- - ZIO.fromAutoCloseable( - ZIO.succeed( - SdkTracerProvider - .builder() - .setResource( - Resource.create( - Attributes.of(ServiceAttributes.SERVICE_NAME, resourceName) - ) - ) - .addSpanProcessor(spanProcessor) - .build() + case None => + ZIO.logInfo( + "No OTLP traces endpoint configured, skipping OpenTelemetry tracing setup. To enable it, set either OTEL_EXPORTER_OTLP_TRACES_ENDPOINT or OTEL_EXPORTER_OTLP_ENDPOINT environment variable." + ) *> ZIO.succeed(None) + case Some(endpoint) => + for { + _ <- ZIO.logInfo(s"Configuring OpenTelemetry tracing to $endpoint") + spanExporter <- ZIO.fromAutoCloseable( + ZIO.succeed(OtlpGrpcSpanExporter.builder().setEndpoint(endpoint).build()) ) - ) - } yield Some(tracerProvider) - - /** https://fluentbit.io/ - */ - def fluentbit(resourceName: String): RIO[Scope, SdkTracerProvider] = - for { - spanExporter <- ZIO.fromAutoCloseable( - ZIO.succeed(OtlpHttpSpanExporter.builder().build()) - ) - spanProcessor <- ZIO.fromAutoCloseable( - ZIO.succeed(SimpleSpanProcessor.create(spanExporter)) - ) - tracerProvider <- - ZIO.fromAutoCloseable( - ZIO.succeed( - SdkTracerProvider - .builder() - .setResource( - Resource.create( - Attributes.of(ServiceAttributes.SERVICE_NAME, resourceName) - ) - ) - .addSpanProcessor(spanProcessor) - .build() + spanProcessor <- ZIO.fromAutoCloseable( + ZIO.succeed(SimpleSpanProcessor.create(spanExporter)) ) - ) - } yield tracerProvider + tracerProvider <- + ZIO.fromAutoCloseable( + ZIO.succeed( + SdkTracerProvider + .builder() + .setResource( + Resource.create( + attributes + ) + ) + .addSpanProcessor(spanProcessor) + .build() + ) + ) + } yield Some(tracerProvider) + } } diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryBase.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryBase.scala index f8d96fb8b8..f7278d0acb 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryBase.scala +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryBase.scala @@ -10,7 +10,9 @@ import zio.telemetry.opentelemetry.OpenTelemetry import io.opentelemetry.sdk.trace.SdkTracerProvider import io.opentelemetry.sdk.metrics.SdkMeterProvider import io.opentelemetry.sdk.logs.SdkLoggerProvider - +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.semconv.ServiceAttributes +import io.opentelemetry.semconv.DeploymentAttributes /** ZIOTelBase is a trait that provides a ZIO layer for OpenTelemetry as bootstrap. * @@ -25,23 +27,40 @@ protected trait ZIOpentelemetryBase { /** The name of the resource, advertised to the OpenTelemetry collector. */ def resourceName: String - /** Whether to enable ZIO internal metrics. - * - * This relies on [[ZioMetrics]] which must be provided **early** by the bootstrap layer. - * - * - * Default is true. - */ + /** The version of the resource, advertised to the OpenTelemetry collector. */ + def version: Option[String] + + /** The environment of the resource, advertised to the OpenTelemetry collector. */ + def environment: Option[String] + + /** Extra attributes to be added to the resource. */ + def extraAttributes: Attributes = Attributes.empty + + /** The attributes of the resource, advertised to the OpenTelemetry collector. */ + def attributes: Attributes = { + val builder = Attributes + .builder() + .put(ServiceAttributes.SERVICE_NAME, resourceName) + version.foreach(v => builder.put(ServiceAttributes.SERVICE_VERSION, v)) + environment.foreach(e => builder.put(DeploymentAttributes.DEPLOYMENT_ENVIRONMENT_NAME, e)) + builder.putAll(extraAttributes).build() + } + + /** Whether to enable ZIO internal metrics. + * + * This relies on [[ZioMetrics]] which must be provided **early** by the bootstrap layer. + * + * Default is true. + */ def withZIOMetrics: Boolean = true - /** The environment for the ZIOpenTelemetry trait. * * This is the environment that will be used to run the ZIO application, hence provided by bootstrap. - * + * * It includes: - * - the OpenTelemetry instance. - * - the ContextStorage instance. + * - the OpenTelemetry instance. + * - the ContextStorage instance. */ override type Environment = api.OpenTelemetry with ContextStorage @@ -49,43 +68,37 @@ protected trait ZIOpentelemetryBase { def environmentTag: Tag[Environment] = Tag[Environment] - - /** - * The console log layer for the ZIOpenTelemetry trait. + /** The console log layer for the ZIOpenTelemetry trait. * * Default implementation uses SLF4J for logging to stdout. */ def consoleLogLayer: ZLayer[Any, Nothing, Unit] = Runtime.removeDefaultLoggers >>> SLF4J.slf4j - /** - * The OpenTelemetry providers for the ZIOpenTelemetry trait. + /** The OpenTelemetry providers for the ZIOpenTelemetry trait. * * @return */ def logProvider: URIO[Scope, Option[SdkLoggerProvider]] = ZIO.none - def meterProvider: URIO[Scope, Option[SdkMeterProvider]] = ZIO.none + def meterProvider: URIO[Scope, Option[SdkMeterProvider]] = ZIO.none - def tracerProvider: URIO[Scope, Option[SdkTracerProvider]] = ZIO.none + def tracerProvider: URIO[Scope, Option[SdkTracerProvider]] = ZIO.none + final def otelProviders: URIO[Scope, OtelProviders] = for { + logger <- logProvider + meter <- meterProvider + tracer <- tracerProvider + } yield OtelProviders(tracer, meter, logger) - final def otelProviders: URIO[Scope, OtelProviders] = for { - logger <- logProvider - meter <- meterProvider - tracer <- tracerProvider - } yield OtelProviders(tracer, meter, logger) - /** The bootstrap layer for the ZIOpenTelemetry trait. * * This is the layer that will be used to bootstrap the ZIO application. It includes the OpenTelemetry layer, the Tracing layer, and the * Meter layer. */ override val bootstrap: ZLayer[ZIOAppArgs, Any, Environment] = - consoleLogLayer >>> - OpenTelemetry.contextZIO >+> (ZLayer.scoped(otelProviders) >>> - ZIOpenTelemetryLayer + consoleLogLayer >>> + OpenTelemetry.contextZIO >+> (ZLayer.scoped[Any](otelProviders) >>> + ZIOpenTelemetryLayer .live(withZIOMetrics)) - - } diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryTracing.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryTracing.scala index d1c5a48f89..7d8c278e85 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryTracing.scala +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryTracing.scala @@ -53,7 +53,6 @@ class ZIOpenTelemetryTracing( import config._ - def newCarrier() = IncomingContextCarrier.default() override def apply[R, B]( diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryTracingConfig.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryTracingConfig.scala index b892f7ec84..4d5d7df9d8 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryTracingConfig.scala +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryTracingConfig.scala @@ -14,16 +14,13 @@ import io.opentelemetry.semconv.ErrorAttributes import scala.annotation.nowarn import zio.telemetry.opentelemetry.tracing.propagation.TraceContextPropagator - /** Configuration for OpenTelemetry Otel4z tracing of server requests, used by [[ZIOpenTelemetry]]. Use the apply method to override only * some of the configuration options, while using the defaults for the rest. * * The default values follow OpenTelemetry semantic conventions, as described in [their * documentation](https://opentelemetry.io/docs/specs/semconv/http/http-spans/#name). - * - * @param tracing - * The tracing instance to use. To obtain it see - * + * @param propagator + * The propagator to use for extracting and injecting trace context. * @param spanName * Calculates the name of the span, given an incoming request. * @param requestAttributes @@ -39,8 +36,6 @@ import zio.telemetry.opentelemetry.tracing.propagation.TraceContextPropagator */ case class ZIOpenTelemetryTracingConfig( propagator: TraceContextPropagator, - - spanName: ServerRequest => String, requestAttributes: ServerRequest => Attributes, spanNameFromEndpointAndAttributes: (ServerRequest, AnyEndpoint) => ( @@ -120,7 +115,7 @@ object ZIOpenTelemetryTracingConfig { ): Attributes = Attributes.of( HttpAttributes.HTTP_RESPONSE_STATUS_CODE, - response.code.code.toLong.asInstanceOf[java.lang.Long] + java.lang.Long.valueOf(response.code.code.toLong) ) def errorAttributes(error: Either[StatusCode, Throwable]): Attributes = diff --git a/observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala b/observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala index 514562c55e..8ce53c96c3 100644 --- a/observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala +++ b/observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala @@ -1,9 +1,8 @@ -package sttp.tapir.server.o11y.otel4s +package sttp.tapir.server.o11y.otel4z import scala.util.{Success, Try} import sttp.capabilities.Streams -import sttp.model._ import sttp.model.Uri._ import sttp.monad.MonadError import sttp.tapir._ @@ -29,7 +28,7 @@ import zio.test.Assertion._ import sttp.tapir.ztapir.RIOMonadError import zio.telemetry.opentelemetry.OpenTelemetry -object ZIOtelTracingTest extends ZIOSpecDefault { +object ZIOtelTracingTest extends ZIOSpecDefault { implicit val bodyListener: BodyListener[Task, String] = new BodyListener[Task, String] { override def onComplete(body: String)(cb: Try[Unit] => Task[Unit]): Task[String] = cb(Success(())).map(_ => body) @@ -72,15 +71,20 @@ object ZIOtelTracingTest extends ZIOSpecDefault { ZIOTestRequestBody, StringToResponseBody, List(ZIOpenTelemetryTracing(tracing)), - _ => ZIO.succeed(()) + _ => ZIO.succeed(()) @@ tracing.aspects.span("interpreter") ) _ <- interpreter(request) exported <- ZIO.service[InMemorySpanExporter] - } yield { + span = exported.getFinishedSpanItems.getFirst() + + _ <- ZIO.debug(s"Span: $span") - assert(exported.getFinishedSpanItems.isEmpty())(isFalse) + } yield { + assert(exported.getFinishedSpanItems.size())(equalTo(1)) && + assert(span.getName)(equalTo("GET /person")) && + assert(span.getAttributes.size())(equalTo(6)) } }).provide( diff --git a/project/Versions.scala b/project/Versions.scala index 46a0768902..64fe1c0cc4 100644 --- a/project/Versions.scala +++ b/project/Versions.scala @@ -66,7 +66,6 @@ object Versions { val decline = "2.6.2" val quicklens = "1.9.12" val openTelemetry = "1.62.0" - val openTelemetryRuntime = "2.27.0-alpha" val openTelemetrySemconvVersion = "1.41.0" val mockServer = "5.15.0" val dogstatsdClient = "4.4.5" From 5f414492d5f57db8d3af4195c38e3f38d6fa572b Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Sat, 16 May 2026 18:54:38 +0200 Subject: [PATCH 05/17] docs(observability): add ZIO OpenTelemetry documentation Add a new section to the observability documentation explaining the integration with the `otel4z` module. This includes: - Dependency information for `tapir-otel4z`. - Overview of the `otel4z` module and its relationship with `zio-opentelemetry`. - Description of available layers: `otel4zLogging`, `otel4zMetrics`, and `otel4zTracing`. - Guidance on using the `ZIOpenTelemetry` trait for application bootstrapping. - Link to the full usage example. --- doc/server/observability.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/doc/server/observability.md b/doc/server/observability.md index 42037d9c98..ea42b21548 100644 --- a/doc/server/observability.md +++ b/doc/server/observability.md @@ -522,4 +522,29 @@ might still serve the request. If a default response (e.g. a `404 Not Found`) should be produced, this should be enabled using the [reject interceptor](errors.md). Such a setup assumes that there are no other routes in the server, after the Tapir -server interpreter is invoked. \ No newline at end of file +server interpreter is invoked. + +## ZIO OpenTelemetry + +ZIO OpenTelemetry integration is provided by the `otel4z` module, which uses the otel4s library under the hood. It provides both logging, tracing and metrics capabilities, as well as a runtime telemetry service for ZIO applications. + + +Add the following dependency: + +```scala +"com.softwaremill.sttp.tapir" %% "tapir-otel4z" % "@VERSION@" +``` + +The `otel4z` module provides integration with the [ZIO OpenTelemetry](https://zio.dev/zio-opentelemetry/) library, which is built on top of the [OpenTelemetry](https://opentelemetry.io/) allowing you to create traces and metrics for your tapir endpoints using a purely functional API. + +This module provides the following layers helpers: +- `otel4zLogging` - a layer that provides the OpenTelemetry logging interceptor, which logs incoming requests and other operations. +- `otel4zMetrics` - a layer that provides the OpenTelemetry metrics interceptor, which records metrics for incoming requests and other operations. +- `otel4zTracing` - a layer that provides the OpenTelemetry tracing interceptor, which creates spans for incoming requests and other operations. + +All of these layers require an OpenTelemetry instance to be provided, but this layer to works with Zio runtime metrics must be provided during the application startup (aka bootstrap). + +The ZIOpenTelemetry trait provide this bootstrap layer, which is used to create the OpenTelemetry instance and provide it to the application. + +Full example of using the `otel4z` module can be found in the [ZIO OpenTelemetry example](https://tapir.softwaremill.com/en/latest/observability/ZIOpenTelemetryExample.scala) + From 69b4c1e830c745b55bdfebb6a11e375b02e3ba2c Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Sat, 16 May 2026 19:19:26 +0200 Subject: [PATCH 06/17] feat(observability): add ZIO OpenTelemetry tracing example and test enhancements --- ...TelemetryExample.scala => ZIOpenTelemetryExample.skip} | 0 .../sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala | 8 +++++--- 2 files changed, 5 insertions(+), 3 deletions(-) rename examples/src/main/scala/sttp/tapir/examples/observability/{ZIOpenTelemetryExample.scala => ZIOpenTelemetryExample.skip} (100%) diff --git a/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.scala b/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip similarity index 100% rename from examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.scala rename to examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip diff --git a/observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala b/observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala index 8ce53c96c3..0fbccd6083 100644 --- a/observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala +++ b/observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala @@ -77,13 +77,15 @@ object ZIOtelTracingTest extends ZIOSpecDefault { exported <- ZIO.service[InMemorySpanExporter] - span = exported.getFinishedSpanItems.getFirst() + fishedSpans = exported.getFinishedSpanItems() + + span = fishedSpans.getFirst() _ <- ZIO.debug(s"Span: $span") } yield { - assert(exported.getFinishedSpanItems.size())(equalTo(1)) && - assert(span.getName)(equalTo("GET /person")) && + assert(fishedSpans.size())(equalTo(1)) && + assert(span.getName())(equalTo("GET /person")) && assert(span.getAttributes.size())(equalTo(6)) } From 61c589581b513687f85144349b4ed241ab1ccad3 Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Sat, 16 May 2026 19:47:54 +0200 Subject: [PATCH 07/17] fix(otel4z): ensure Java 11 compatibility in ZIOtelTracingTest Replace `getFirst()` with `get(0)` when retrieving finished spans to prevent runtime errors on Java 11 environments where `getFirst()` is unavailable. --- .../scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala b/observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala index 0fbccd6083..8436d667ae 100644 --- a/observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala +++ b/observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala @@ -79,7 +79,7 @@ object ZIOtelTracingTest extends ZIOSpecDefault { fishedSpans = exported.getFinishedSpanItems() - span = fishedSpans.getFirst() + span = fishedSpans.get(0) // getFirst not supported in Java 11 _ <- ZIO.debug(s"Span: $span") From 506aaa832ea800c861f24a25f4617deb6a0dfca6 Mon Sep 17 00:00:00 2001 From: "olivier.nouguier" Date: Tue, 19 May 2026 17:32:05 +0200 Subject: [PATCH 08/17] refactor(otel4z): extract buildExporter method for cleaner metric exporter configuration --- .../server/o11y/otel4z/MeterProvider.scala | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/MeterProvider.scala b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/MeterProvider.scala index ca2b284ccf..49d9e40ce5 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/MeterProvider.scala +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/MeterProvider.scala @@ -7,15 +7,32 @@ import io.opentelemetry.sdk.resources.Resource import io.opentelemetry.api.common.Attributes import io.opentelemetry.exporter.otlp.metrics.OtlpGrpcMetricExporter +import io.opentelemetry.sdk.metrics.`export`.AggregationTemporalitySelector /** Provides a meter provider for OpenTelemetry. */ object MeterProvider { + private def buildExporter(endpoint: String) = { + + val builder = OtlpGrpcMetricExporter + .builder() + sys.env + .get("OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE") + .filter(v => v.toUpperCase().equals("DELTA")) + .foreach(_ => builder.setAggregationTemporalitySelector(AggregationTemporalitySelector.deltaPreferred())) + + builder + .setEndpoint(endpoint) + .build() + } + /** Provides a meter provider for OpenTelemetry, which logs in OTLP Json format as gRPC if either of the following environment variables * is set: * - `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` * - `OTEL_EXPORTER_OTLP_ENDPOINT` + * If `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE` environment variable is set to "DELTA" (case insensitive), the exporter will be + * configured to prefer delta temporality for metrics, otherwise it will use the default cumulative temporality. */ def grpc(attributes: Attributes): URIO[Scope, Option[SdkMeterProvider]] = OtlpEndpoint("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT") match { case None => @@ -27,7 +44,7 @@ object MeterProvider { for { _ <- ZIO.logInfo(s"Configuring OpenTelemetry metrics to $endpoint") metricExporter <- ZIO.fromAutoCloseable( - ZIO.succeed(OtlpGrpcMetricExporter.builder().setEndpoint(endpoint).build()) + ZIO.succeed(buildExporter(endpoint)) ) metricReader <- ZIO.fromAutoCloseable( From effcf8e36cb4f6b521e1140a24811ef08791784b Mon Sep 17 00:00:00 2001 From: "olivier.nouguier" Date: Thu, 21 May 2026 14:04:13 +0200 Subject: [PATCH 09/17] Refactor observability module: Replace otel4z with zio-opentelemetry - Updated build.sbt to replace references from otel4z to zioOpenTelemetry. - Renamed the project from otel4z to zioOpenTelemetry and updated its settings. - Removed the old ZIOpenTelemetry trait and replaced it with a new implementation under the zio-opentelemetry module. - Introduced new LoggerProvider, MeterProvider, TracerProvider, and OtlpEndpoint classes for OpenTelemetry integration. - Added ZIOpenTelemetryTracing and ZIOpenTelemetryTracingConfig for tracing requests. - Created test applications and tests for the new zio-opentelemetry functionality. --- build.sbt | 8 ++++---- .../observability/ZIOpenTelemetryExample.skip | 2 +- .../tapir/server/o11y/otel4z/ZIOpenTelemetry.scala | 13 ------------- .../o11y/ziopentelemetry}/ZIOpenTelemetry.scala | 10 ++++------ .../o11y/ziopentelemetry/ZIOpenTelemetry.scala | 12 ++++++++++++ .../o11y/ziopentelemetry}/LoggerProvider.scala | 2 +- .../o11y/ziopentelemetry}/MeterProvider.scala | 2 +- .../server/o11y/ziopentelemetry}/OtlpEndpoint.scala | 2 +- .../server/o11y/ziopentelemetry}/Providers.scala | 8 ++++---- .../o11y/ziopentelemetry}/TracerProvider.scala | 2 +- .../o11y/ziopentelemetry}/ZIOpenTelemetryBase.scala | 4 ++-- .../ziopentelemetry}/ZIOpenTelemetryLayer.scala | 2 +- .../ziopentelemetry}/ZIOpenTelemetryTracing.scala | 12 ++++++------ .../ZIOpenTelemetryTracingConfig.scala | 2 +- .../ziopentelemetry}/ZIOtelTracingTestApp.scala | 2 +- .../ziopentelemetry}/ZIOtelTracingTestApp.scala | 2 +- .../o11y/ziopentelemetry}/ZIOtelTracingTest.scala | 7 +++---- 17 files changed, 44 insertions(+), 48 deletions(-) delete mode 100644 observability/otel4z/src/main/scala-3/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala rename observability/{otel4z/src/main/scala-2/sttp/tapir/server/o11y/otel4z => zio-opentelemetry/src/main/scala-2/sttp/tapir/server/o11y/ziopentelemetry}/ZIOpenTelemetry.scala (55%) create mode 100644 observability/zio-opentelemetry/src/main/scala-3/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetry.scala rename observability/{otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z => zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry}/LoggerProvider.scala (97%) rename observability/{otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z => zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry}/MeterProvider.scala (98%) rename observability/{otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z => zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry}/OtlpEndpoint.scala (92%) rename observability/{otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z => zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry}/Providers.scala (95%) rename observability/{otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z => zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry}/TracerProvider.scala (97%) rename observability/{otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z => zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry}/ZIOpenTelemetryBase.scala (97%) rename observability/{otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z => zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry}/ZIOpenTelemetryLayer.scala (97%) rename observability/{otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z => zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry}/ZIOpenTelemetryTracing.scala (96%) rename observability/{otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z => zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry}/ZIOpenTelemetryTracingConfig.scala (99%) rename observability/{otel4z/src/test/scala-2/sttp/tapir/server/o11y/otel4z => zio-opentelemetry/src/test/scala-2/sttp/tapir/server/o11y/ziopentelemetry}/ZIOtelTracingTestApp.scala (76%) rename observability/{otel4z/src/test/scala-3/sttp/tapir/server/o11y/otel4z => zio-opentelemetry/src/test/scala-3/sttp/tapir/server/o11y/ziopentelemetry}/ZIOtelTracingTestApp.scala (71%) rename observability/{otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z => zio-opentelemetry/src/test/scala/sttp/tapir/server/o11y/ziopentelemetry}/ZIOtelTracingTest.scala (95%) diff --git a/build.sbt b/build.sbt index a0b39dfab3..e042dc57ef 100644 --- a/build.sbt +++ b/build.sbt @@ -193,7 +193,7 @@ lazy val rawAllAggregates = core.projectRefs ++ opentelemetryTracing.projectRefs ++ otel4sMetrics.projectRefs ++ otel4sTracing.projectRefs ++ - otel4z.projectRefs ++ + zioOpenTelemetry.projectRefs ++ json4s.projectRefs ++ playJson.projectRefs ++ play29Json.projectRefs ++ @@ -1181,11 +1181,11 @@ lazy val otel4sMetrics: ProjectMatrix = (projectMatrix in file("metrics/otel4s-m .jvmPlatform(scalaVersions = scala2_13And3Versions, settings = commonJvmSettings) .dependsOn(serverCore % CompileAndTest, catsEffect % Test) -lazy val otel4z: ProjectMatrix = (projectMatrix in file("observability/otel4z")) +lazy val zioOpenTelemetry: ProjectMatrix = (projectMatrix in file("observability/zio-opentelemetry")) .dependsOn(zio, zioHttpServer, opentelemetryMetrics) .settings(commonSettings) .settings( - name := "tapir-otel4z", + name := "tapir-zio-opentelemetry", libraryDependencies ++= Seq( "dev.zio" %% "zio-logging" % Versions.zioLogging, "dev.zio" %% "zio-logging-slf4j2" % Versions.zioLogging, @@ -2386,7 +2386,7 @@ lazy val examples: ProjectMatrix = (projectMatrix in file("examples")) zioHttpServer, zioJson, zioMetrics, - otel4z + zioOpenTelemetry ) //TODO this should be invoked by compilation process, see #https://github.com/scalameta/mdoc/issues/355 diff --git a/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip b/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip index d1facf6bbe..11ef1f45ce 100644 --- a/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip +++ b/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip @@ -6,7 +6,7 @@ //> using dep com.softwaremill.sttp.tapir::tapir-zio-http-server:1.13.18 //> using dep com.softwaremill.sttp.tapir::tapir-swagger-ui-bundle:1.13.18 //> using dep com.softwaremill.sttp.tapir::tapir-zio:1.13.18 -//> using dep com.softwaremill.sttp.tapir::tapir-otel4z:1.13.18 +//> using dep com.softwaremill.sttp.tapir::tapir-zio-opentelemetry:1.13.18 package sttp.tapir.examples.observability diff --git a/observability/otel4z/src/main/scala-3/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala b/observability/otel4z/src/main/scala-3/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala deleted file mode 100644 index f56b35ba06..0000000000 --- a/observability/otel4z/src/main/scala-3/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala +++ /dev/null @@ -1,13 +0,0 @@ -package sttp.tapir.server.o11y.otel4z - -import zio.ZIOApp - -/** ZIOpenTelemetry is a trait that provides a ZIO layer for OpenTelemetry. - * @param name - */ -trait ZIOpenTelemetry(val resourceName: String, val version: Option[String]=None, val environment: Option[String]=None) extends ZIOpentelemetryBase { - this: ZIOApp => -} - - - diff --git a/observability/otel4z/src/main/scala-2/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala b/observability/zio-opentelemetry/src/main/scala-2/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetry.scala similarity index 55% rename from observability/otel4z/src/main/scala-2/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala rename to observability/zio-opentelemetry/src/main/scala-2/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetry.scala index 82535a1280..8b0ce495ba 100644 --- a/observability/otel4z/src/main/scala-2/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetry.scala +++ b/observability/zio-opentelemetry/src/main/scala-2/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetry.scala @@ -1,16 +1,14 @@ -package sttp.tapir.server.o11y.otel4z +package sttp.tapir.server.o11y.ziopentelemetry import zio.ZIOApp /** ZIOpenTelemetry is a trait that provides a ZIO layer for OpenTelemetry. * @param name */ -trait ZIOpenTelemetry extends ZIOpentelemetryBase { +trait ZIOpenTelemetry extends ZIOpenTelemetryBase { this: ZIOApp => def version: Option[String] = None - - def environment: Option[String] = None -} - + def environment: Option[String] = None +} diff --git a/observability/zio-opentelemetry/src/main/scala-3/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetry.scala b/observability/zio-opentelemetry/src/main/scala-3/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetry.scala new file mode 100644 index 0000000000..91ab888885 --- /dev/null +++ b/observability/zio-opentelemetry/src/main/scala-3/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetry.scala @@ -0,0 +1,12 @@ +package sttp.tapir.server.o11y.ziopentelemetry + +import zio.ZIOApp +import sttp.tapir.server.o11y.ziopentelemetry.ZIOpenTelemetryBase + +/** ZIOpenTelemetry is a trait that provides a ZIO layer for OpenTelemetry. + * @param name + */ +trait ZIOpenTelemetry(val resourceName: String, val version: Option[String] = None, val environment: Option[String] = None) + extends ZIOpenTelemetryBase { + this: ZIOApp => +} diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/LoggerProvider.scala b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/LoggerProvider.scala similarity index 97% rename from observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/LoggerProvider.scala rename to observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/LoggerProvider.scala index 366678294e..3db81720e4 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/LoggerProvider.scala +++ b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/LoggerProvider.scala @@ -1,4 +1,4 @@ -package sttp.tapir.server.o11y.otel4z +package sttp.tapir.server.o11y.ziopentelemetry import io.opentelemetry.api.common.Attributes diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/MeterProvider.scala b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/MeterProvider.scala similarity index 98% rename from observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/MeterProvider.scala rename to observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/MeterProvider.scala index 49d9e40ce5..27151ac469 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/MeterProvider.scala +++ b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/MeterProvider.scala @@ -1,4 +1,4 @@ -package sttp.tapir.server.o11y.otel4z +package sttp.tapir.server.o11y.ziopentelemetry import zio._ import io.opentelemetry.sdk.metrics.SdkMeterProvider diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/OtlpEndpoint.scala b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/OtlpEndpoint.scala similarity index 92% rename from observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/OtlpEndpoint.scala rename to observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/OtlpEndpoint.scala index 8756f54cc7..f09fb4ae33 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/OtlpEndpoint.scala +++ b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/OtlpEndpoint.scala @@ -1,4 +1,4 @@ -package sttp.tapir.server.o11y.otel4z +package sttp.tapir.server.o11y.ziopentelemetry object OtlpEndpoint { diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/Providers.scala b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/Providers.scala similarity index 95% rename from observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/Providers.scala rename to observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/Providers.scala index 438c4df304..f8016c86e3 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/Providers.scala +++ b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/Providers.scala @@ -1,4 +1,4 @@ -package sttp.tapir.server.o11y.otel4z +package sttp.tapir.server.o11y.ziopentelemetry import zio._ @@ -15,7 +15,7 @@ import zio.telemetry.opentelemetry.context.ContextStorage /** Logging, Metrics and Tracing providers for OpenTelemetry. */ trait Logging { - this: ZIOpentelemetryBase => + this: ZIOpenTelemetryBase => /** Provides a logger provider for OpenTelemetry, which logs in OTLP Json format to stdout. */ @@ -39,7 +39,7 @@ trait Logging { /** Metrics provider for OpenTelemetry. */ trait Metrics { - this: ZIOpentelemetryBase => + this: ZIOpenTelemetryBase => /** Provides a meter provider for OpenTelemetry, which exports metrics in OTLP Json format to stdout. */ @@ -86,7 +86,7 @@ trait Metrics { } trait Traces { - this: ZIOpentelemetryBase => + this: ZIOpenTelemetryBase => override def tracerProvider: URIO[Scope, Option[SdkTracerProvider]] = TracerProvider.grpc(attributes) diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/TracerProvider.scala b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/TracerProvider.scala similarity index 97% rename from observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/TracerProvider.scala rename to observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/TracerProvider.scala index fe87a66640..08e4fcee00 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/TracerProvider.scala +++ b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/TracerProvider.scala @@ -1,4 +1,4 @@ -package sttp.tapir.server.o11y.otel4z +package sttp.tapir.server.o11y.ziopentelemetry import io.opentelemetry.api.common.Attributes import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryBase.scala b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryBase.scala similarity index 97% rename from observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryBase.scala rename to observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryBase.scala index f7278d0acb..9a0fa1db95 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryBase.scala +++ b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryBase.scala @@ -1,4 +1,4 @@ -package sttp.tapir.server.o11y.otel4z +package sttp.tapir.server.o11y.ziopentelemetry import io.opentelemetry.api import zio._ @@ -21,7 +21,7 @@ import io.opentelemetry.semconv.DeploymentAttributes * - Uses SLF4J for logging to stdout. * - Logs, Metrics, Traces are sent to the OpenTelemetry collector through gRPC. */ -protected trait ZIOpentelemetryBase { +protected trait ZIOpenTelemetryBase { this: ZIOApp => /** The name of the resource, advertised to the OpenTelemetry collector. */ diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryLayer.scala b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryLayer.scala similarity index 97% rename from observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryLayer.scala rename to observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryLayer.scala index e653f92413..e6e4b69330 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryLayer.scala +++ b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryLayer.scala @@ -1,4 +1,4 @@ -package sttp.tapir.server.o11y.otel4z +package sttp.tapir.server.o11y.ziopentelemetry import zio._ import io.opentelemetry.api diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryTracing.scala b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryTracing.scala similarity index 96% rename from observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryTracing.scala rename to observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryTracing.scala index 7d8c278e85..e6d415ec4a 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryTracing.scala +++ b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryTracing.scala @@ -1,4 +1,4 @@ -package sttp.tapir.server.o11y.otel4z +package sttp.tapir.server.o11y.ziopentelemetry import zio._ @@ -21,15 +21,15 @@ import io.opentelemetry.api.trace.StatusCode import io.opentelemetry.api.common.Attributes import zio.telemetry.opentelemetry.context.IncomingContextCarrier -/** Interceptor which traces requests using otel4s. +/** Interceptor which traces requests using ZIO OpenTelemetry. * - * Span names and attributes are calculated using the provided [[ZIOtelTracingConfig]]. + * Span names and attributes are calculated using the provided [[ZIOpenTelemetryTracingConfig]]. * * To use, customize the interceptors of the server interpreter you are using, and prepend this interceptor, so that it runs as early as * possible, e.g.: * * {{{ - * protected def serverOptions(using + * def serverOptions(using * tracing: Tracing * ): ZioHttpServerOptions[Any] = * ZioHttpServerOptions.customiseInterceptors @@ -97,7 +97,7 @@ class ZIOpenTelemetryTracing( )(request, endpoints) _ <- requestResult match { case Response(response, _) => - setSpanAttibutes( + setSpanAttributes( span, responseAttributes(request, response) ) *> ZIO.when(response.isServerError)( @@ -176,7 +176,7 @@ class ZIOpenTelemetryTracing( span.setAllAttributes(errorAttributes(error)) }.unit - private def setSpanAttibutes( + private def setSpanAttributes( span: Span, attributes: Attributes ): Task[Unit] = diff --git a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryTracingConfig.scala b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryTracingConfig.scala similarity index 99% rename from observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryTracingConfig.scala rename to observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryTracingConfig.scala index 4d5d7df9d8..bb6faa21e8 100644 --- a/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/ZIOpenTelemetryTracingConfig.scala +++ b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryTracingConfig.scala @@ -1,4 +1,4 @@ -package sttp.tapir.server.o11y.otel4z +package sttp.tapir.server.o11y.ziopentelemetry import sttp.model.headers.{Forwarded, Host} import sttp.model.{HeaderNames, StatusCode} diff --git a/observability/otel4z/src/test/scala-2/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTestApp.scala b/observability/zio-opentelemetry/src/test/scala-2/sttp/tapir/server/o11y/ziopentelemetry/ZIOtelTracingTestApp.scala similarity index 76% rename from observability/otel4z/src/test/scala-2/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTestApp.scala rename to observability/zio-opentelemetry/src/test/scala-2/sttp/tapir/server/o11y/ziopentelemetry/ZIOtelTracingTestApp.scala index 19d2b17027..7c8dda565a 100644 --- a/observability/otel4z/src/test/scala-2/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTestApp.scala +++ b/observability/zio-opentelemetry/src/test/scala-2/sttp/tapir/server/o11y/ziopentelemetry/ZIOtelTracingTestApp.scala @@ -1,4 +1,4 @@ -package sttp.tapir.server.o11y.otel4z +package sttp.tapir.server.o11y.ziopentelemetry import zio._ diff --git a/observability/otel4z/src/test/scala-3/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTestApp.scala b/observability/zio-opentelemetry/src/test/scala-3/sttp/tapir/server/o11y/ziopentelemetry/ZIOtelTracingTestApp.scala similarity index 71% rename from observability/otel4z/src/test/scala-3/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTestApp.scala rename to observability/zio-opentelemetry/src/test/scala-3/sttp/tapir/server/o11y/ziopentelemetry/ZIOtelTracingTestApp.scala index 1dd2cdd304..ab34d64648 100644 --- a/observability/otel4z/src/test/scala-3/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTestApp.scala +++ b/observability/zio-opentelemetry/src/test/scala-3/sttp/tapir/server/o11y/ziopentelemetry/ZIOtelTracingTestApp.scala @@ -1,4 +1,4 @@ -package sttp.tapir.server.o11y.otel4z +package sttp.tapir.server.o11y.ziopentelemetry import zio.* diff --git a/observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala b/observability/zio-opentelemetry/src/test/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOtelTracingTest.scala similarity index 95% rename from observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala rename to observability/zio-opentelemetry/src/test/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOtelTracingTest.scala index 8436d667ae..86c31563d9 100644 --- a/observability/otel4z/src/test/scala/sttp/tapir/server/o11y/otel4z/ZIOtelTracingTest.scala +++ b/observability/zio-opentelemetry/src/test/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOtelTracingTest.scala @@ -1,4 +1,4 @@ -package sttp.tapir.server.o11y.otel4z +package sttp.tapir.server.o11y.ziopentelemetry import scala.util.{Success, Try} @@ -10,7 +10,7 @@ import sttp.tapir.TestUtil.serverRequestFromUri import sttp.tapir.capabilities.NoStreams import sttp.tapir.model.ServerRequest import sttp.tapir.server.interpreter._ -import sttp.tapir.server.o11y.otel4z.ZIOpenTelemetryTracing +import sttp.tapir.server.o11y.ziopentelemetry.ZIOpenTelemetryTracing import sttp.tapir.server.TestUtil.StringToResponseBody import io.opentelemetry.api.trace.Tracer @@ -24,11 +24,10 @@ import zio.telemetry.opentelemetry.tracing.Tracing import zio.test._ import zio.test.Assertion._ - import sttp.tapir.ztapir.RIOMonadError import zio.telemetry.opentelemetry.OpenTelemetry -object ZIOtelTracingTest extends ZIOSpecDefault { +object ZIOtelTracingTest extends ZIOSpecDefault { implicit val bodyListener: BodyListener[Task, String] = new BodyListener[Task, String] { override def onComplete(body: String)(cb: Try[Unit] => Task[Unit]): Task[String] = cb(Success(())).map(_ => body) From df8b422051417582a5942bca3593b3ad6fed4218 Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Thu, 21 May 2026 23:18:24 +0200 Subject: [PATCH 10/17] refactor(ziopentelemetry): replace newCarrier method with extractCarrier for improved header handling --- .../o11y/ziopentelemetry/ZIOpenTelemetryTracing.scala | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryTracing.scala b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryTracing.scala index e6d415ec4a..abdc3bce82 100644 --- a/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryTracing.scala +++ b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryTracing.scala @@ -2,6 +2,8 @@ package sttp.tapir.server.o11y.ziopentelemetry import zio._ +import collection.mutable.{Map => MutableMap} + import sttp.monad.MonadError import sttp.model.{StatusCode => SttpStatusCode} import sttp.tapir.AnyEndpoint @@ -53,7 +55,7 @@ class ZIOpenTelemetryTracing( import config._ - def newCarrier() = IncomingContextCarrier.default() + private def extractCarrier(request: ServerRequest) = IncomingContextCarrier.default(MutableMap.from(request.headers.map(h => (h.name, h.value)))) override def apply[R, B]( responder: Responder[Task, B], @@ -67,7 +69,7 @@ class ZIOpenTelemetryTracing( )(implicit monad: MonadError[Task]): Task[RequestResult[B]] = tracing .extractSpanUnsafe( config.propagator, - newCarrier(), + extractCarrier(request), request.showShort, spanKind = SpanKind.SERVER, attributes = config.requestAttributes(request) From 1d7545dc57711f164e3aa428abc2c4bfe0e86c31 Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Thu, 21 May 2026 23:46:23 +0200 Subject: [PATCH 11/17] feat(observability): add ZIO OpenTelemetry tracing tests and remove deprecated ZIOtelTracingTest --- ...lTracingTestApp.scala => TestZIOApp.scala} | 0 ...lTracingTestApp.scala => TestZIOApp.scala} | 0 .../ZIOpenTelemetryTracingSpec.scala | 665 ++++++++++++++++++ .../ziopentelemetry/ZIOtelTracingTest.scala | 101 --- 4 files changed, 665 insertions(+), 101 deletions(-) rename observability/zio-opentelemetry/src/test/scala-2/sttp/tapir/server/o11y/ziopentelemetry/{ZIOtelTracingTestApp.scala => TestZIOApp.scala} (100%) rename observability/zio-opentelemetry/src/test/scala-3/sttp/tapir/server/o11y/ziopentelemetry/{ZIOtelTracingTestApp.scala => TestZIOApp.scala} (100%) create mode 100644 observability/zio-opentelemetry/src/test/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryTracingSpec.scala delete mode 100644 observability/zio-opentelemetry/src/test/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOtelTracingTest.scala diff --git a/observability/zio-opentelemetry/src/test/scala-2/sttp/tapir/server/o11y/ziopentelemetry/ZIOtelTracingTestApp.scala b/observability/zio-opentelemetry/src/test/scala-2/sttp/tapir/server/o11y/ziopentelemetry/TestZIOApp.scala similarity index 100% rename from observability/zio-opentelemetry/src/test/scala-2/sttp/tapir/server/o11y/ziopentelemetry/ZIOtelTracingTestApp.scala rename to observability/zio-opentelemetry/src/test/scala-2/sttp/tapir/server/o11y/ziopentelemetry/TestZIOApp.scala diff --git a/observability/zio-opentelemetry/src/test/scala-3/sttp/tapir/server/o11y/ziopentelemetry/ZIOtelTracingTestApp.scala b/observability/zio-opentelemetry/src/test/scala-3/sttp/tapir/server/o11y/ziopentelemetry/TestZIOApp.scala similarity index 100% rename from observability/zio-opentelemetry/src/test/scala-3/sttp/tapir/server/o11y/ziopentelemetry/ZIOtelTracingTestApp.scala rename to observability/zio-opentelemetry/src/test/scala-3/sttp/tapir/server/o11y/ziopentelemetry/TestZIOApp.scala diff --git a/observability/zio-opentelemetry/src/test/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryTracingSpec.scala b/observability/zio-opentelemetry/src/test/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryTracingSpec.scala new file mode 100644 index 0000000000..90471ba6be --- /dev/null +++ b/observability/zio-opentelemetry/src/test/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryTracingSpec.scala @@ -0,0 +1,665 @@ +package sttp.tapir.server.o11y.ziopentelemetry + +import scala.jdk.CollectionConverters._ +import scala.util.{Success, Try} + +import sttp.capabilities.Streams +import sttp.model.{Header, HeaderNames, Method, Uri} +import sttp.model.Uri._ +import sttp.model.headers.Forwarded +import sttp.monad.MonadError +import sttp.tapir._ +import sttp.tapir.TestUtil.serverRequestFromUri +import sttp.tapir.capabilities.NoStreams +import sttp.tapir.model.ServerRequest +import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.interpreter._ +import sttp.tapir.server.o11y.ziopentelemetry.ZIOpenTelemetryTracing +import sttp.tapir.server.TestUtil.StringToResponseBody + +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.trace.{SpanKind, Tracer, StatusCode => OtelStatusCode} +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter +import io.opentelemetry.sdk.trace.`export`.SimpleSpanProcessor +import io.opentelemetry.sdk.trace.SdkTracerProvider +import io.opentelemetry.sdk.trace.data.SpanData +import io.opentelemetry.semconv.HttpAttributes +import io.opentelemetry.semconv.UrlAttributes +import io.opentelemetry.semconv.ServerAttributes +import io.opentelemetry.semconv.ErrorAttributes + +import zio._ +import zio.telemetry.opentelemetry.context.ContextStorage +import zio.telemetry.opentelemetry.tracing.Tracing +import zio.test._ +import zio.test.Assertion._ + +import sttp.tapir.ztapir.RIOMonadError +import zio.telemetry.opentelemetry.OpenTelemetry + +object ZIOpenTelemetryTracingSpec extends ZIOSpecDefault { + + implicit val bodyListener: BodyListener[Task, String] = new BodyListener[Task, String] { + override def onComplete(body: String)(cb: Try[Unit] => Task[Unit]): Task[String] = cb(Success(())).map(_ => body) + } + + implicit val ioErr: MonadError[Task] = new RIOMonadError + + val inMemoryTracer: UIO[(InMemorySpanExporter, Tracer)] = for { + spanExporter <- ZIO.succeed(InMemorySpanExporter.create()) + spanProcessor <- ZIO.succeed(SimpleSpanProcessor.create(spanExporter)) + tracerProvider <- ZIO.succeed(SdkTracerProvider.builder().addSpanProcessor(spanProcessor).build()) + tracer = tracerProvider.get("TracingTest") + } yield (spanExporter, tracer) + + val inMemoryTracerLayer: ULayer[InMemorySpanExporter with Tracer] = + ZLayer.fromZIOEnvironment(inMemoryTracer.map { case (inMemorySpanExporter, tracer) => + ZEnvironment(inMemorySpanExporter).add(tracer) + }) + + def tracingMockLayer( + logAnnotated: Boolean = false + ): URLayer[ContextStorage, Tracing with InMemorySpanExporter with Tracer] = + inMemoryTracerLayer >>> (Tracing.live(logAnnotated) ++ inMemoryTracerLayer) + + /** Helper: create an interpreter with the given interceptor and endpoints, run a request, return finished spans. */ + private def runRequest( + endpoints: List[ServerEndpoint[Any, Task]], + request: ServerRequest, + config: ZIOpenTelemetryTracingConfig = ZIOpenTelemetryTracingConfig() + ): ZIO[Tracing with InMemorySpanExporter, Throwable, java.util.List[SpanData]] = + for { + tracing <- ZIO.service[Tracing] + exported <- ZIO.service[InMemorySpanExporter] + _ <- ZIO.succeed(exported.reset()) + interpreter = new ServerInterpreter[Any, Task, String, NoStreams]( + _ => endpoints, + ZIOTestRequestBody, + StringToResponseBody, + List(ZIOpenTelemetryTracing(tracing, config)), + _ => ZIO.succeed(()) + ) + _ <- interpreter(request) + spans = exported.getFinishedSpanItems() + } yield spans + + /** Helper: run request and return single span. */ + private def runRequestSingleSpan( + endpoints: List[ServerEndpoint[Any, Task]], + request: ServerRequest, + config: ZIOpenTelemetryTracingConfig = ZIOpenTelemetryTracingConfig() + ): ZIO[Tracing with InMemorySpanExporter, Throwable, SpanData] = + runRequest(endpoints, request, config).map(_.get(0)) + + // Tests are provided with layers at the suite level via .provide() + + // ─── Span Creation & Naming ──────────────────────────────────────────────── + + private val spanNamingSuite = suite("Span Creation & Naming")( + test("report a simple trace") { + val ep = endpoint + .in("person") + .in(query[String]("name")) + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.succeed(Right("hello"))) + + val request = serverRequestFromUri(uri"http://example.com/person?name=Adam") + for { + span <- runRequestSingleSpan(List(ep), request) + } yield { + assert(span.getName)(equalTo("GET /person")) && + assert(span.getKind)(equalTo(SpanKind.SERVER)) && + assert(span.getAttributes.size())(equalTo(6)) + } + }, + test("use path template with path parameters as span name") { + val ep = endpoint + .in("person" / path[String]("name") / path[String]("surname") / "info") + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.succeed(Right("hello"))) + + val request = serverRequestFromUri(uri"http://example.com/person/Adam/Smith/info") + for { + span <- runRequestSingleSpan(List(ep), request) + } yield { + assert(span.getName)(equalTo("GET /person/{name}/{surname}/info")) && + assert(span.getAttributes.get(HttpAttributes.HTTP_ROUTE))(equalTo("/person/{name}/{surname}/info")) + } + }, + test("use POST method in span name") { + val ep = endpoint.post + .in("person") + .in(query[String]("name")) + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.succeed(Right("created"))) + + val request = serverRequestFromUri(uri"http://example.com/person?name=Adam", _method = Method.POST) + for { + span <- runRequestSingleSpan(List(ep), request) + } yield { + assert(span.getName)(equalTo("POST /person")) && + assert(span.getAttributes.get(HttpAttributes.HTTP_REQUEST_METHOD))(equalTo("POST")) + } + }, + test("use complex path template with mixed segments") { + val ep = endpoint + .in("api" / "v2" / "users" / path[Int]("id") / "profile") + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.succeed(Right("profile"))) + + val request = serverRequestFromUri(uri"http://example.com/api/v2/users/123/profile") + for { + span <- runRequestSingleSpan(List(ep), request) + } yield { + assert(span.getName)(equalTo("GET /api/v2/users/{id}/profile")) + } + } + ) + + // ─── Request Attributes ──────────────────────────────────────────────────── + + private val requestAttributesSuite = suite("Request Attributes")( + test("default request attributes include method, path, scheme, host") { + val ep = endpoint + .in("person") + .in(query[String]("name")) + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.succeed(Right("hello"))) + + val request = serverRequestFromUri( + uri"http://example.com/person?name=Adam", + _headers = List(Header(HeaderNames.Host, "example.com")) + ) + for { + span <- runRequestSingleSpan(List(ep), request) + } yield { + val attrs = span.getAttributes + assert(attrs.get(HttpAttributes.HTTP_REQUEST_METHOD))(equalTo("GET")) && + assert(attrs.get(UrlAttributes.URL_PATH))(equalTo("/person")) && + assert(attrs.get(UrlAttributes.URL_SCHEME))(equalTo("http")) && + assert(attrs.get(ServerAttributes.SERVER_ADDRESS))(equalTo("example.com")) + } + }, + test("extract host from Forwarded header") { + val ep = endpoint + .in("person") + .in(query[String]("name")) + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.succeed(Right("hello"))) + + val request = serverRequestFromUri( + uri"http://example.com/person?name=Adam", + _headers = List(Header(HeaderNames.Forwarded, Forwarded(None, None, Some("softwaremill.com"), None).toString)) + ) + for { + span <- runRequestSingleSpan(List(ep), request) + } yield { + assert(span.getAttributes.get(ServerAttributes.SERVER_ADDRESS))(equalTo("softwaremill.com")) + } + }, + test("extract host from X-Forwarded-Host header") { + val ep = endpoint + .in("hello") + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.succeed(Right("hello"))) + + val request = serverRequestFromUri( + uri"http://example.com/hello", + _headers = List(Header(HeaderNames.XForwardedHost, "proxy.example.com")) + ) + for { + span <- runRequestSingleSpan(List(ep), request) + } yield { + assert(span.getAttributes.get(ServerAttributes.SERVER_ADDRESS))(equalTo("proxy.example.com")) + } + }, + test("extract host from :authority pseudo-header") { + val ep = endpoint + .in("hello") + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.succeed(Right("hello"))) + + val request = serverRequestFromUri( + uri"http://example.com/hello", + _headers = List(Header(":authority", "authority.example.com")) + ) + for { + span <- runRequestSingleSpan(List(ep), request) + } yield { + assert(span.getAttributes.get(ServerAttributes.SERVER_ADDRESS))(equalTo("authority.example.com")) + } + }, + test("extract host from Host header") { + val ep = endpoint + .in("hello") + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.succeed(Right("hello"))) + + val request = serverRequestFromUri( + uri"http://example.com/hello", + _headers = List(Header(HeaderNames.Host, "host.example.com")) + ) + for { + span <- runRequestSingleSpan(List(ep), request) + } yield { + assert(span.getAttributes.get(ServerAttributes.SERVER_ADDRESS))(equalTo("host.example.com")) + } + }, + test("fallback to 'unknown' when no host headers present") { + val ep = endpoint + .in("hello") + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.succeed(Right("hello"))) + + // serverRequestFromUri with empty headers and a URI without explicit Host header + val request = serverRequestFromUri(uri"http://example.com/hello") + for { + span <- runRequestSingleSpan(List(ep), request) + } yield { + // When there's no Host/Forwarded/X-Forwarded-Host/:authority header, defaults to "unknown" + assert(span.getAttributes.get(ServerAttributes.SERVER_ADDRESS))(equalTo("unknown")) + } + }, + test("detect HTTPS scheme") { + val ep = endpoint + .in("secure") + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.succeed(Right("secure"))) + + val request = serverRequestFromUri(uri"https://example.com/secure") + for { + span <- runRequestSingleSpan(List(ep), request) + } yield { + assert(span.getAttributes.get(UrlAttributes.URL_SCHEME))(equalTo("https")) + } + } + ) + + // ─── Response Attributes ─────────────────────────────────────────────────── + + private val responseAttributesSuite = suite("Response Attributes")( + test("200 OK response sets status code attribute") { + val ep = endpoint + .in("person") + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.succeed(Right("hello"))) + + val request = serverRequestFromUri(uri"http://example.com/person") + for { + span <- runRequestSingleSpan(List(ep), request) + } yield { + assert(span.getAttributes.get(HttpAttributes.HTTP_RESPONSE_STATUS_CODE))(equalTo(java.lang.Long.valueOf(200L))) + } + }, + test("404 error response sets status code attribute") { + val ep = endpoint + .in("person") + .out(stringBody) + .errorOut(statusCode(sttp.model.StatusCode.NotFound)) + .serverLogic[Task](_ => ZIO.succeed(Left(()))) + + val request = serverRequestFromUri(uri"http://example.com/person") + for { + span <- runRequestSingleSpan(List(ep), request) + } yield { + assert(span.getAttributes.get(HttpAttributes.HTTP_RESPONSE_STATUS_CODE))(equalTo(java.lang.Long.valueOf(404L))) + } + } + ) + + // ─── Error Handling ──────────────────────────────────────────────────────── + + private val errorHandlingSuite = suite("Error Handling")( + test("5xx server error sets span error status") { + val ep = endpoint + .in("fail") + .out(stringBody) + .errorOut(statusCode(sttp.model.StatusCode.InternalServerError)) + .serverLogic[Task](_ => ZIO.succeed(Left(()))) + + val request = serverRequestFromUri(uri"http://example.com/fail") + for { + span <- runRequestSingleSpan(List(ep), request) + } yield { + assert(span.getStatus.getStatusCode)(equalTo(OtelStatusCode.ERROR)) && + assert(span.getAttributes.get(ErrorAttributes.ERROR_TYPE))(equalTo("500")) + } + }, + test("exception in endpoint logic sets span error status") { + val ep = endpoint + .in("crash") + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.fail(new RuntimeException("boom"))) + + val request = serverRequestFromUri(uri"http://example.com/crash") + for { + _ <- runRequest(List(ep), request).either + exported <- ZIO.service[InMemorySpanExporter] + finishedSpans = exported.getFinishedSpanItems() + } yield { + // The span should be recorded even if there was an exception + assertTrue(finishedSpans.size() >= 1) && + assert(finishedSpans.get(0).getStatus.getStatusCode)(equalTo(OtelStatusCode.ERROR)) && + assert(finishedSpans.get(0).getAttributes.get(ErrorAttributes.ERROR_TYPE))(equalTo("RuntimeException")) + } + }, + test("4xx client error does NOT set span error status") { + val ep = endpoint + .in("client-error") + .out(stringBody) + .errorOut(statusCode(sttp.model.StatusCode.BadRequest)) + .serverLogic[Task](_ => ZIO.succeed(Left(()))) + + val request = serverRequestFromUri(uri"http://example.com/client-error") + for { + span <- runRequestSingleSpan(List(ep), request) + } yield { + // 4xx is not a server error, so span status should NOT be ERROR + assert(span.getStatus.getStatusCode)(not(equalTo(OtelStatusCode.ERROR))) && + assert(span.getAttributes.get(HttpAttributes.HTTP_RESPONSE_STATUS_CODE))(equalTo(java.lang.Long.valueOf(400L))) + } + } + ) + + // ─── Context Propagation ─────────────────────────────────────────────────── + + private val contextPropagationSuite = suite("Context Propagation")( + test("extract trace context from traceparent header") { + val ep = endpoint + .in("hello") + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.succeed(Right("hello"))) + + val request = serverRequestFromUri( + uri"http://example.com/hello", + _headers = List(Header("traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")) + ) + for { + span <- runRequestSingleSpan(List(ep), request) + } yield { + assert(span.getSpanContext.getTraceId)(equalTo("4bf92f3577b34da6a3ce929d0e0e4736")) && + assert(span.getParentSpanContext.getSpanId)(equalTo("00f067aa0ba902b7")) && + assertTrue(span.getParentSpanContext.isRemote) + } + }, + test("create fresh trace when no traceparent header is present") { + val ep = endpoint + .in("hello") + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.succeed(Right("hello"))) + + val request = serverRequestFromUri(uri"http://example.com/hello") + for { + span <- runRequestSingleSpan(List(ep), request) + } yield { + // Should have a valid trace ID but no valid parent span context + assertTrue(span.getSpanContext.isValid) && + assertTrue(!span.getParentSpanContext.isValid) + } + }, + test("malformed traceparent starts a new trace gracefully") { + val ep = endpoint + .in("hello") + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.succeed(Right("hello"))) + + val request = serverRequestFromUri( + uri"http://example.com/hello", + _headers = List(Header("traceparent", "invalid-traceparent-value")) + ) + for { + span <- runRequestSingleSpan(List(ep), request) + } yield { + // Should gracefully start a new trace + assertTrue(span.getSpanContext.isValid) && + assertTrue(!span.getParentSpanContext.isValid) + } + } + ) + + // ─── Endpoint Matching Behavior ──────────────────────────────────────────── + + private val endpointMatchingSuite = suite("Endpoint Matching Behavior")( + test("unmatched request does not set error status") { + val ep = endpoint + .in("person") + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.succeed(Right("hello"))) + + // Request to a path that doesn't match any endpoint + val request = serverRequestFromUri(uri"http://example.com/unknown-path") + for { + spans <- runRequest(List(ep), request) + } yield { + assertTrue(spans.size() == 1) && + assert(spans.get(0).getStatus.getStatusCode)(not(equalTo(OtelStatusCode.ERROR))) + } + }, + test("decode failure on matched endpoint - span is created with initial name") { + val ep = endpoint + .in("person") + .in(query[Int]("age")) // expects Int, will fail with String + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.succeed(Right("hello"))) + + // Send "notanint" for the Int query parameter - decode will fail + val request = serverRequestFromUri(uri"http://example.com/person?age=notanint") + for { + spans <- runRequest(List(ep), request) + } yield { + // A span is still created even when decode fails; + // without a DecodeFailureHandler interceptor producing a response, + // the endpoint is not confirmed, so span retains the initial request-based name. + assertTrue(spans.size() >= 1) && + assertTrue(spans.get(0).getKind == SpanKind.SERVER) && + assert(spans.get(0).getStatus.getStatusCode)(not(equalTo(OtelStatusCode.ERROR))) + } + }, + test("security failure on matched endpoint updates span name") { + val ep = endpoint + .securityIn("secure") + .securityIn(header[String]("X-Auth-Token")) + .in("data") + .out(stringBody) + .errorOut(stringBody) + .serverSecurityLogic[String, Task](token => + if (token == "valid") ZIO.succeed(Right("principal")) + else ZIO.succeed(Left("unauthorized")) + ) + .serverLogic(_ => _ => ZIO.succeed(Right("data"))) + + val request = serverRequestFromUri( + uri"http://example.com/secure/data", + _headers = List(Header("X-Auth-Token", "invalid")) + ) + for { + spans <- runRequest(List(ep), request) + } yield { + assertTrue(spans.size() >= 1) && + assert(spans.get(0).getName)(equalTo("GET /secure/data")) + } + } + ) + + // ─── Configuration Customization ────────────────────────────────────────── + + private val configCustomizationSuite = suite("Configuration Customization")( + test("custom spanNameFromEndpointAndAttributes overrides default naming") { + val customConfig = ZIOpenTelemetryTracingConfig( + spanNameFromEndpointAndAttributes = (request, _) => (s"custom-${request.method.method}", Attributes.empty()) + ) + + val ep = endpoint + .in("person") + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.succeed(Right("hello"))) + + val request = serverRequestFromUri(uri"http://example.com/person") + for { + span <- runRequestSingleSpan(List(ep), request, customConfig) + } yield { + assert(span.getName)(equalTo("custom-GET")) + } + }, + test("custom requestAttributes adds custom attributes to span") { + val customConfig = ZIOpenTelemetryTracingConfig( + requestAttributes = request => + Attributes.builder() + .put(HttpAttributes.HTTP_REQUEST_METHOD, request.method.method) + .put("custom.attribute", "custom-value") + .build() + ) + + val ep = endpoint + .in("person") + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.succeed(Right("hello"))) + + val request = serverRequestFromUri(uri"http://example.com/person") + for { + span <- runRequestSingleSpan(List(ep), request, customConfig) + } yield { + import io.opentelemetry.api.common.AttributeKey + val customAttr = span.getAttributes.get(AttributeKey.stringKey("custom.attribute")) + assert(customAttr)(equalTo("custom-value")) + } + }, + test("custom responseAttributes adds extra response attributes") { + import io.opentelemetry.api.common.AttributeKey + val customConfig = ZIOpenTelemetryTracingConfig( + responseAttributes = (_, response) => + Attributes.of( + HttpAttributes.HTTP_RESPONSE_STATUS_CODE, java.lang.Long.valueOf(response.code.code.toLong), + AttributeKey.stringKey("custom.response"), "response-value" + ) + ) + + val ep = endpoint + .in("person") + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](_ => ZIO.succeed(Right("hello"))) + + val request = serverRequestFromUri(uri"http://example.com/person") + for { + span <- runRequestSingleSpan(List(ep), request, customConfig) + } yield { + import io.opentelemetry.api.common.AttributeKey + val customAttr = span.getAttributes.get(AttributeKey.stringKey("custom.response")) + assert(customAttr)(equalTo("response-value")) + } + }, + test("custom errorAttributes for server error") { + val customConfig = ZIOpenTelemetryTracingConfig( + errorAttributes = { + case Left(statusCode) => + Attributes.builder() + .put(ErrorAttributes.ERROR_TYPE, s"custom-${statusCode.code}") + .build() + case Right(exception) => + Attributes.builder() + .put(ErrorAttributes.ERROR_TYPE, s"custom-${exception.getClass.getSimpleName}") + .build() + } + ) + + val ep = endpoint + .in("fail") + .out(stringBody) + .errorOut(statusCode(sttp.model.StatusCode.InternalServerError)) + .serverLogic[Task](_ => ZIO.succeed(Left(()))) + + val request = serverRequestFromUri(uri"http://example.com/fail") + for { + span <- runRequestSingleSpan(List(ep), request, customConfig) + } yield { + assert(span.getAttributes.get(ErrorAttributes.ERROR_TYPE))(equalTo("custom-500")) + } + } + ) + + // ─── Concurrency ────────────────────────────────────────────────────────── + + private val concurrencySuite = suite("Concurrency")( + test("concurrent requests produce isolated spans with distinct trace IDs") { + val ep = endpoint + .in("person") + .in(query[String]("name")) + .out(stringBody) + .errorOut(stringBody) + .serverLogic[Task](name => ZIO.succeed(Right(s"hello $name"))) + + for { + tracing <- ZIO.service[Tracing] + exported <- ZIO.service[InMemorySpanExporter] + _ <- ZIO.succeed(exported.reset()) + interpreter = new ServerInterpreter[Any, Task, String, NoStreams]( + _ => List(ep), + ZIOTestRequestBody, + StringToResponseBody, + List(ZIOpenTelemetryTracing(tracing)), + _ => ZIO.succeed(()) + ) + names = (1 to 20).map(i => s"user$i").toList + _ <- ZIO.foreachPar(names) { name => + val request = serverRequestFromUri(Uri.unsafeParse(s"http://example.com/person?name=$name")) + interpreter(request) + } + spans = exported.getFinishedSpanItems().asScala.toList + traceIds = spans.map(_.getSpanContext.getTraceId).toSet + } yield { + // Each request should produce its own span + assertTrue(spans.size == 20) && + // All trace IDs should be distinct (each is an independent trace) + assertTrue(traceIds.size == 20) && + // All spans should be SERVER spans + assertTrue(spans.forall(_.getKind == SpanKind.SERVER)) && + // All spans should have the matched endpoint name + assertTrue(spans.forall(_.getName == "GET /person")) + } + } + ) + + // ─── Main Spec ───────────────────────────────────────────────────────────── + + def spec: Spec[Any, Throwable] = + suite("zio opentelemetry tapir interceptor")( + spanNamingSuite, + requestAttributesSuite, + responseAttributesSuite, + errorHandlingSuite, + contextPropagationSuite, + endpointMatchingSuite, + configCustomizationSuite, + concurrencySuite + ).provide( + OpenTelemetry.contextZIO, + tracingMockLayer(false) + ) @@ TestAspect.sequential +} + +object ZIOTestRequestBody extends RequestBody[Task, NoStreams] { + override def toRaw[R](serverRequest: ServerRequest, bodyType: RawBodyType[R], maxBytes: Option[Long]): Task[RawValue[R]] = ??? + override val streams: Streams[NoStreams] = NoStreams + override def toStream(serverRequest: ServerRequest, maxBytes: Option[Long]): streams.BinaryStream = ??? +} diff --git a/observability/zio-opentelemetry/src/test/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOtelTracingTest.scala b/observability/zio-opentelemetry/src/test/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOtelTracingTest.scala deleted file mode 100644 index 86c31563d9..0000000000 --- a/observability/zio-opentelemetry/src/test/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOtelTracingTest.scala +++ /dev/null @@ -1,101 +0,0 @@ -package sttp.tapir.server.o11y.ziopentelemetry - -import scala.util.{Success, Try} - -import sttp.capabilities.Streams -import sttp.model.Uri._ -import sttp.monad.MonadError -import sttp.tapir._ -import sttp.tapir.TestUtil.serverRequestFromUri -import sttp.tapir.capabilities.NoStreams -import sttp.tapir.model.ServerRequest -import sttp.tapir.server.interpreter._ -import sttp.tapir.server.o11y.ziopentelemetry.ZIOpenTelemetryTracing -import sttp.tapir.server.TestUtil.StringToResponseBody - -import io.opentelemetry.api.trace.Tracer -import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter -import io.opentelemetry.sdk.trace.`export`.SimpleSpanProcessor -import io.opentelemetry.sdk.trace.SdkTracerProvider - -import zio._ -import zio.telemetry.opentelemetry.context.ContextStorage -import zio.telemetry.opentelemetry.tracing.Tracing -import zio.test._ -import zio.test.Assertion._ - -import sttp.tapir.ztapir.RIOMonadError -import zio.telemetry.opentelemetry.OpenTelemetry - -object ZIOtelTracingTest extends ZIOSpecDefault { - - implicit val bodyListener: BodyListener[Task, String] = new BodyListener[Task, String] { - override def onComplete(body: String)(cb: Try[Unit] => Task[Unit]): Task[String] = cb(Success(())).map(_ => body) - } - - implicit val ioErr: MonadError[Task] = new RIOMonadError - - val inMemoryTracer: UIO[(InMemorySpanExporter, Tracer)] = for { - spanExporter <- ZIO.succeed(InMemorySpanExporter.create()) - spanProcessor <- ZIO.succeed(SimpleSpanProcessor.create(spanExporter)) - tracerProvider <- ZIO.succeed(SdkTracerProvider.builder().addSpanProcessor(spanProcessor).build()) - tracer = tracerProvider.get("TracingTest") - } yield (spanExporter, tracer) - - val inMemoryTracerLayer: ULayer[InMemorySpanExporter with Tracer] = - ZLayer.fromZIOEnvironment(inMemoryTracer.map { case (inMemorySpanExporter, tracer) => - ZEnvironment(inMemorySpanExporter).add(tracer) - }) - - def tracingMockLayer( - logAnnotated: Boolean = false - ): URLayer[ContextStorage, Tracing with InMemorySpanExporter with Tracer] = - inMemoryTracerLayer >>> (Tracing.live(logAnnotated) ++ inMemoryTracerLayer) - - def spec: Spec[Any, Throwable] = - suite("zio opentelemetry tapir interceptor")(test("report a simple trace") { - for { - _ <- ZIO.logDebug("Setting up in-memory tracer and tracing layer") - tracing <- ZIO.service[Tracing] - endpointa = endpoint - .in("person") - .in(query[String]("name")) - .out(stringBody) - .errorOut(stringBody) - .serverLogic[Task](_ => ZIO.succeed(Right("hello"))) - - request = serverRequestFromUri(uri"http://example.com/person?name=Adam") - interpreter = new ServerInterpreter[Any, Task, String, NoStreams]( - _ => List(endpointa), - ZIOTestRequestBody, - StringToResponseBody, - List(ZIOpenTelemetryTracing(tracing)), - _ => ZIO.succeed(()) @@ tracing.aspects.span("interpreter") - ) - _ <- interpreter(request) - - exported <- ZIO.service[InMemorySpanExporter] - - fishedSpans = exported.getFinishedSpanItems() - - span = fishedSpans.get(0) // getFirst not supported in Java 11 - - _ <- ZIO.debug(s"Span: $span") - - } yield { - assert(fishedSpans.size())(equalTo(1)) && - assert(span.getName())(equalTo("GET /person")) && - assert(span.getAttributes.size())(equalTo(6)) - } - - }).provide( - OpenTelemetry.contextZIO, - tracingMockLayer(false) - ) -} - -object ZIOTestRequestBody extends RequestBody[Task, NoStreams] { - override def toRaw[R](serverRequest: ServerRequest, bodyType: RawBodyType[R], maxBytes: Option[Long]): Task[RawValue[R]] = ??? - override val streams: Streams[NoStreams] = NoStreams - override def toStream(serverRequest: ServerRequest, maxBytes: Option[Long]): streams.BinaryStream = ??? -} From cd202de6b858efbf9e50f70a778671b775ed343c Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Fri, 22 May 2026 09:18:23 +0200 Subject: [PATCH 12/17] refactor(ziopentelemetry): scala 2.12 compat, extractCarrier Mutable Map. --- .../o11y/ziopentelemetry/ZIOpenTelemetryTracing.scala | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryTracing.scala b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryTracing.scala index abdc3bce82..983662113b 100644 --- a/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryTracing.scala +++ b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryTracing.scala @@ -55,7 +55,12 @@ class ZIOpenTelemetryTracing( import config._ - private def extractCarrier(request: ServerRequest) = IncomingContextCarrier.default(MutableMap.from(request.headers.map(h => (h.name, h.value)))) + private def extractCarrier(request: ServerRequest) = { + val headers = request.headers + val carrier = MutableMap.empty[String, String] + headers.foreach(h => carrier.put(h.name, h.value)) + IncomingContextCarrier.default(carrier) + } override def apply[R, B]( responder: Responder[Task, B], From bd1bdd0cf32c2c29f475c1eb4af870e5410b1941 Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Sat, 23 May 2026 00:57:28 +0200 Subject: [PATCH 13/17] refactor(observability): remove zio-logging dependencies and update logging implementation in ZIOpenTelemetry --- build.sbt | 2 -- .../examples/observability/ZIOpenTelemetryExample.skip | 5 ++++- .../o11y/ziopentelemetry/ZIOpenTelemetryBase.scala | 10 +++++++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/build.sbt b/build.sbt index e042dc57ef..d1a7856bcc 100644 --- a/build.sbt +++ b/build.sbt @@ -1187,8 +1187,6 @@ lazy val zioOpenTelemetry: ProjectMatrix = (projectMatrix in file("observability .settings( name := "tapir-zio-opentelemetry", libraryDependencies ++= Seq( - "dev.zio" %% "zio-logging" % Versions.zioLogging, - "dev.zio" %% "zio-logging-slf4j2" % Versions.zioLogging, "dev.zio" %% "zio-opentelemetry" % Versions.zioOpenTelemetry, "dev.zio" %% "zio-opentelemetry-zio-logging" % Versions.zioOpenTelemetry, "io.opentelemetry.semconv" % "opentelemetry-semconv" % Versions.openTelemetrySemconvVersion, diff --git a/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip b/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip index 11ef1f45ce..5bbdba7940 100644 --- a/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip +++ b/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip @@ -7,13 +7,14 @@ //> using dep com.softwaremill.sttp.tapir::tapir-swagger-ui-bundle:1.13.18 //> using dep com.softwaremill.sttp.tapir::tapir-zio:1.13.18 //> using dep com.softwaremill.sttp.tapir::tapir-zio-opentelemetry:1.13.18 +//> using dep dev.zio::zio-logging-slf4j2:0.7.44 package sttp.tapir.examples.observability import io.opentelemetry.api.OpenTelemetry import sttp.tapir.server.ServerEndpoint -import sttp.tapir.server.o11y.otel4z._ +import sttp.tapir.server.o11y.ziopentelemetry._ import sttp.tapir.server.ziohttp._ import sttp.tapir.ztapir._ @@ -23,6 +24,7 @@ import zio.telemetry.opentelemetry.metrics.Meter import zio.telemetry.opentelemetry.tracing.Tracing import sttp.tapir.server.interceptor.cors.CORSInterceptor import io.opentelemetry.api.common.Attributes +import zio.logging.backend.SLF4J @@ -44,6 +46,7 @@ object ZIOpenTelemetryExample extends ZIOApp with ZIOpenTelemetry("zio-observabi * program. */ + override def consoleLogLayer: ZLayer[Any, Nothing, Unit] = Runtime.removeDefaultLoggers >>> SLF4J.slf4j override def extraAttributes: Attributes = Attributes.builder().put("stack", "zio").build() diff --git a/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryBase.scala b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryBase.scala index 9a0fa1db95..3ead94718b 100644 --- a/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryBase.scala +++ b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryBase.scala @@ -2,7 +2,6 @@ package sttp.tapir.server.o11y.ziopentelemetry import io.opentelemetry.api import zio._ -import zio.logging.backend.SLF4J import zio.telemetry.opentelemetry.context.ContextStorage import zio.telemetry.opentelemetry.OpenTelemetry @@ -70,9 +69,14 @@ protected trait ZIOpenTelemetryBase { /** The console log layer for the ZIOpenTelemetry trait. * - * Default implementation uses SLF4J for logging to stdout. + * Default implementation uses the default ZIO console logger, which logs to stdout. + * You can override this to use a different logger, e.g. SLF4J, Logback, etc. + * To use SLF4J, you can use the following layer: + * {{{ + * def consoleLogLayer: ZLayer[Any, Nothing, Unit] = Runtime.removeDefaultLoggers >>> SLF4J.slf4j + * }}} */ - def consoleLogLayer: ZLayer[Any, Nothing, Unit] = Runtime.removeDefaultLoggers >>> SLF4J.slf4j + def consoleLogLayer: ZLayer[Any, Nothing, Unit] = ZLayer.unit /** The OpenTelemetry providers for the ZIOpenTelemetry trait. * From 8fa5ddf5ab4ba846f39bbcc1df8c6a74ef05b4c9 Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Sat, 23 May 2026 01:34:46 +0200 Subject: [PATCH 14/17] chore: update Tapir and ZIO dependencies to version 1.13.19 and 2.5.3 --- .../observability/ZIOpenTelemetryExample.skip | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip b/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip index 5bbdba7940..a9f0dfdf1a 100644 --- a/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip +++ b/examples/src/main/scala/sttp/tapir/examples/observability/ZIOpenTelemetryExample.skip @@ -1,13 +1,16 @@ // {cat=Hello, World!; effects=ZIO; server=ZIO HTTP; json=zio; docs=Swagger UI}: ZIO OpenTelemetry tracing example //> using option -Xkind-projector -//> using dep com.softwaremill.sttp.tapir::tapir-core:1.13.18 -//> using dep com.softwaremill.sttp.tapir::tapir-json-circe:1.13.18 -//> using dep com.softwaremill.sttp.tapir::tapir-zio-http-server:1.13.18 -//> using dep com.softwaremill.sttp.tapir::tapir-swagger-ui-bundle:1.13.18 -//> using dep com.softwaremill.sttp.tapir::tapir-zio:1.13.18 -//> using dep com.softwaremill.sttp.tapir::tapir-zio-opentelemetry:1.13.18 -//> using dep dev.zio::zio-logging-slf4j2:0.7.44 +//> using dep com.softwaremill.sttp.tapir::tapir-core::1.13.19 +//> using dep com.softwaremill.sttp.tapir::tapir-json-circe:1.13.19 +//> using dep com.softwaremill.sttp.tapir::tapir-zio-http-server:1.13.19 +//> using dep com.softwaremill.sttp.tapir::tapir-swagger-ui-bundle:1.13.19 +//> using dep com.softwaremill.sttp.tapir::tapir-zio:1.13.19 +//> using dep com.softwaremill.sttp.tapir::tapir-zio-opentelemetry:1.13.19 +//> using dep dev.zio::zio-logging:2.5.3 +//> using dep dev.zio::zio-logging-slf4j2:2.5.3 +//> using dep ch.qos.logback:logback-classic:1.5.32 + package sttp.tapir.examples.observability @@ -19,12 +22,13 @@ import sttp.tapir.server.ziohttp._ import sttp.tapir.ztapir._ import zio._ +import zio.logging.backend.SLF4J import zio.http._ import zio.telemetry.opentelemetry.metrics.Meter import zio.telemetry.opentelemetry.tracing.Tracing import sttp.tapir.server.interceptor.cors.CORSInterceptor import io.opentelemetry.api.common.Attributes -import zio.logging.backend.SLF4J + From 86cfaf5a86fbaa47f85475db60f58d0b7674a2c7 Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Sat, 23 May 2026 22:21:06 +0200 Subject: [PATCH 15/17] refactor(observability): update documentation for Logging and Metrics providers to clarify OTLP gRPC format usage --- .../o11y/ziopentelemetry/Providers.scala | 24 +++++++++++++++---- .../ziopentelemetry/ZIOpenTelemetryBase.scala | 23 +++++++++++++----- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/Providers.scala b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/Providers.scala index f8016c86e3..30c0601753 100644 --- a/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/Providers.scala +++ b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/Providers.scala @@ -12,12 +12,16 @@ import io.opentelemetry.sdk.logs.SdkLoggerProvider import zio.telemetry.opentelemetry.context.ContextStorage -/** Logging, Metrics and Tracing providers for OpenTelemetry. +/** Logging provider for OpenTelemetry. + * + * The providers are configured to export logs in OTLP gRPC format to collector. + * + * The providers are used by the OpenTelemetry layers, which are/must be provided by the [[ZIOpenTelemetry]] trait bootstrap layer. */ trait Logging { this: ZIOpenTelemetryBase => - /** Provides a logger provider for OpenTelemetry, which logs in OTLP Json format to stdout. + /** Provides a logger provider for OpenTelemetry, which logs in OTLP gRPC format with [[LoggerProvider]] */ override def logProvider: URIO[Scope, Option[SdkLoggerProvider]] = LoggerProvider.grpc(attributes) @@ -36,12 +40,16 @@ trait Logging { ) } -/** Metrics provider for OpenTelemetry. +/** Metrics providers for OpenTelemetry. + * + * The providers are configured to export metricsin OTLP gRPC format to collector. + * + * The providers are used by the OpenTelemetry layers, which are/must be provided by the [[ZIOpenTelemetry]] trait bootstrap layer. */ trait Metrics { this: ZIOpenTelemetryBase => - /** Provides a meter provider for OpenTelemetry, which exports metrics in OTLP Json format to stdout. + /** Provides a meter provider for OpenTelemetry, which exports metrics in OTLP gRPC format with [[MeterProvider]] */ override def meterProvider: URIO[Scope, Option[SdkMeterProvider]] = MeterProvider.grpc(attributes) @@ -85,9 +93,17 @@ trait Metrics { } +/** Tracing providers for OpenTelemetry. + * + * The providers are configured to export traces in OTLP gRPC format to collector. + * + * The providers are used by the OpenTelemetry layers, which are/must be provided by the [[ZIOpenTelemetry]] trait bootstrap layer. + */ trait Traces { this: ZIOpenTelemetryBase => + /** Provides a tracer provider for OpenTelemetry, which exports traces in OTLP gRPC format with [[TracerProvider]] + */ override def tracerProvider: URIO[Scope, Option[SdkTracerProvider]] = TracerProvider.grpc(attributes) def otel4zTracing( diff --git a/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryBase.scala b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryBase.scala index 3ead94718b..b3fb0848e0 100644 --- a/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryBase.scala +++ b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryBase.scala @@ -17,7 +17,6 @@ import io.opentelemetry.semconv.DeploymentAttributes * * By default, it uses the OTEL_EXPORTER_OTLP_ENDPOINT environment variable to configure the OpenTelemetry exporter. * - * - Uses SLF4J for logging to stdout. * - Logs, Metrics, Traces are sent to the OpenTelemetry collector through gRPC. */ protected trait ZIOpenTelemetryBase { @@ -69,23 +68,35 @@ protected trait ZIOpenTelemetryBase { /** The console log layer for the ZIOpenTelemetry trait. * - * Default implementation uses the default ZIO console logger, which logs to stdout. - * You can override this to use a different logger, e.g. SLF4J, Logback, etc. - * To use SLF4J, you can use the following layer: + * Default implementation uses the default ZIO console logger, which logs to stdout. You can override this to use a different logger, + * e.g. SLF4J, Logback, etc. To use SLF4J, you can use the following layer: * {{{ * def consoleLogLayer: ZLayer[Any, Nothing, Unit] = Runtime.removeDefaultLoggers >>> SLF4J.slf4j * }}} */ def consoleLogLayer: ZLayer[Any, Nothing, Unit] = ZLayer.unit - /** The OpenTelemetry providers for the ZIOpenTelemetry trait. + /** The OpenTelemetry [[SdkLoggerProvider]] for the ZIOpenTelemetry trait. * - * @return + * By default, no logger provider is provided. You can override this to provide a logger provider, e.g. to export logs in OTLP gRPC + * format to collector. + * + * Or mixing in the [[Logging]] trait, which provides a logger provider that exports logs in OTLP gRPC format to collector. */ def logProvider: URIO[Scope, Option[SdkLoggerProvider]] = ZIO.none + /** The OpenTelemetry [[SdkMeterProvider]] for the ZIOpenTelemetry trait. By default, no meter provider is provided. You can override this + * to provide a meter provider, e.g. to export metrics in OTLP gRPC format to collector. + * + * Or mixing in the [[Metrics]] trait, which provides a meter provider that exports metrics in OTLP gRPC format to collector. + */ def meterProvider: URIO[Scope, Option[SdkMeterProvider]] = ZIO.none + /** The OpenTelemetry [[SdkTracerProvider]] for the ZIOpenTelemetry trait. By default, no tracer provider is provided. You can override + * this to provide a tracer provider, e.g. to export traces in OTLP gRPC format to collector. + * + * Or mixing in the [[Traces]] trait, which provides a tracer provider that exports traces in OTLP gRPC format to collector. + */ def tracerProvider: URIO[Scope, Option[SdkTracerProvider]] = ZIO.none final def otelProviders: URIO[Scope, OtelProviders] = for { From 3185f7a18d7ba3bb1c80069026a9ab9f2cf39dc2 Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Sat, 23 May 2026 22:43:03 +0200 Subject: [PATCH 16/17] refactor(observability): change otelProviders to return ULayer Update `otelProviders` to return `ULayer[OtelProviders]` instead of `URIO[Scope, OtelProviders]`. This change simplifies the bootstrap layer composition by providing the providers as a scoped layer directly. --- .../o11y/ziopentelemetry/ZIOpenTelemetryBase.scala | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryBase.scala b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryBase.scala index b3fb0848e0..21c72cb426 100644 --- a/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryBase.scala +++ b/observability/zio-opentelemetry/src/main/scala/sttp/tapir/server/o11y/ziopentelemetry/ZIOpenTelemetryBase.scala @@ -99,11 +99,16 @@ protected trait ZIOpenTelemetryBase { */ def tracerProvider: URIO[Scope, Option[SdkTracerProvider]] = ZIO.none - final def otelProviders: URIO[Scope, OtelProviders] = for { + /** The OpenTelemetry providers for the ZIOpenTelemetry trait. + * + * This is the layer that will be used to provide the OpenTelemetry providers to the ZIO application. It includes the OpenTelemetry + * logger provider, the OpenTelemetry meter provider, and the OpenTelemetry tracer provider. + */ + final def otelProviders: ULayer[OtelProviders] = ZLayer.scoped[Any](for { logger <- logProvider meter <- meterProvider tracer <- tracerProvider - } yield OtelProviders(tracer, meter, logger) + } yield OtelProviders(tracer, meter, logger)) /** The bootstrap layer for the ZIOpenTelemetry trait. * @@ -112,7 +117,7 @@ protected trait ZIOpenTelemetryBase { */ override val bootstrap: ZLayer[ZIOAppArgs, Any, Environment] = consoleLogLayer >>> - OpenTelemetry.contextZIO >+> (ZLayer.scoped[Any](otelProviders) >>> + OpenTelemetry.contextZIO >+> (otelProviders >>> ZIOpenTelemetryLayer .live(withZIOMetrics)) From 1847909aebae5ac6f68fd078a1713bd7394c225a Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Sat, 23 May 2026 22:43:22 +0200 Subject: [PATCH 17/17] build(deps): add zio-logging-slf4j2 dependency --- build.sbt | 1 + 1 file changed, 1 insertion(+) diff --git a/build.sbt b/build.sbt index d1a7856bcc..53691e9e79 100644 --- a/build.sbt +++ b/build.sbt @@ -2343,6 +2343,7 @@ lazy val examples: ProjectMatrix = (projectMatrix in file("examples")) "io.opentelemetry" % "opentelemetry-sdk-extension-autoconfigure" % Versions.openTelemetry, "com.github.plokhotnyuk.jsoniter-scala" %%% "jsoniter-scala-macros" % Versions.jsoniter, "org.typelevel" %% "otel4s-oteljava" % Versions.otel4s, + "dev.zio" %% "zio-logging-slf4j2" % Versions.zioLogging, scalaTest.value, logback ),