From 6760a45519b6b80f54d13781945d6f5b24952a08 Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Fri, 1 May 2026 21:33:48 +0200 Subject: [PATCH 1/7] 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 800d5327b1..5b16554b2c 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 49378ee2deca91f5083e6ce178ac9fa089a77c29 Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Fri, 15 May 2026 22:46:41 +0200 Subject: [PATCH 2/7] 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 0e9395d40de9cbc23c43d783b9e415b19c111367 Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Sat, 16 May 2026 16:28:26 +0200 Subject: [PATCH 3/7] 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 0504ce280507cb70c069aaaea2e6e9ae8100d13d Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Sat, 16 May 2026 18:28:19 +0200 Subject: [PATCH 4/7] 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 5b16554b2c..83196e333f 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 1e68e9d7816aa7fcf90475826daf5b5991d1379b Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Sat, 16 May 2026 18:54:38 +0200 Subject: [PATCH 5/7] 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 557a5a4a95de6da8428ad4d68c750b4f831d1e3c Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Sat, 16 May 2026 19:19:26 +0200 Subject: [PATCH 6/7] 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 e61972e48d4f9013a6f7dfb0866302d2d3e9d404 Mon Sep 17 00:00:00 2001 From: Olivier NOUGUIER Date: Sat, 16 May 2026 19:47:54 +0200 Subject: [PATCH 7/7] 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")