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..227579d1f4 --- /dev/null +++ b/observability/otel4z/src/main/scala/sttp/tapir/server/o11y/otel4z/Providers.scala @@ -0,0 +1,76 @@ +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(implicit 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) + +} 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"