Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Versions.zioHttp
import com.softwaremill.Publish.{ossPublishSettings, updateDocs}
import com.softwaremill.SbtSoftwareMillBrowserTestJS._
import com.softwaremill.SbtSoftwareMillCommon.commonSmlBuildSettings
Expand Down Expand Up @@ -192,6 +193,7 @@ lazy val rawAllAggregates = core.projectRefs ++
opentelemetryTracing.projectRefs ++
otel4sMetrics.projectRefs ++
otel4sTracing.projectRefs ++
otel4z.projectRefs ++
json4s.projectRefs ++
playJson.projectRefs ++
play29Json.projectRefs ++
Expand Down Expand Up @@ -1179,6 +1181,28 @@ 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,
"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"))
Expand Down Expand Up @@ -2361,7 +2385,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
Expand Down
27 changes: 26 additions & 1 deletion doc/server/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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)

Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// {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
import io.opentelemetry.api.common.Attributes




/** 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", Some("1.0.0"), Some("dev")) 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.
*/


override def extraAttributes: Attributes = Attributes.builder().put("stack", "zio").build()


// 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](

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),

// 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
* instance and the ContextStorage.
*/
private def serverOptions(implicit
otel: OpenTelemetry,
tracing: Tracing
): ZioHttpServerOptions[Any] = ZioHttpServerOptions.customiseInterceptors
.prependInterceptor(
ZIOpenTelemetryTracing(tracing)
)
.appendInterceptor(
CORSInterceptor.default
)
.appendInterceptor(otel4zMetricsInterceptor())
.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 ()
)
}

/*

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" % <version>

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
.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)

}
Original file line number Diff line number Diff line change
@@ -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 ZIOpentelemetryBase {
this: ZIOApp =>

def version: Option[String] = None

def environment: Option[String] = None
}


Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package sttp.tapir.server.o11y.otel4z

import zio.ZIOApp

/** ZIOpenTelemetry is a trait that provides a ZIO layer for OpenTelemetry.
* @param name
*/
trait ZIOpenTelemetry(val resourceName: String, val version: Option[String]=None, val environment: Option[String]=None) extends ZIOpentelemetryBase {
this: ZIOApp =>
}



Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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.exporter.otlp.logs.OtlpGrpcLogRecordExporter

/** Provides a logger provider for OpenTelemetry.
*/
object LoggerProvider {

/** 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(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))
)
loggerProvider <-
ZIO.fromAutoCloseable(
ZIO.succeed(
SdkLoggerProvider
.builder()
.setResource(
Resource.create(
attributes
)
)
.addLogRecordProcessor(logRecordProcessor)
.build()
)
)
} yield Some(loggerProvider)

}
}
Loading
Loading