diff --git a/build.sbt b/build.sbt index cec990efc8..30b704a384 100644 --- a/build.sbt +++ b/build.sbt @@ -169,6 +169,7 @@ lazy val logback = "ch.qos.logback" % "logback-classic" % Versions.logback lazy val slf4j = "org.slf4j" % "slf4j-api" % Versions.slf4j lazy val rawAllAggregates = core.projectRefs ++ + opentelemetryTracingSync.projectRefs ++ testing.projectRefs ++ cats.projectRefs ++ catsEffect.projectRefs ++ @@ -256,7 +257,13 @@ lazy val rawAllAggregates = core.projectRefs ++ derevo.projectRefs ++ awsCdk.projectRefs -lazy val loomProjects: Seq[String] = Seq(nettyServerSync, nimaServer, examples, documentation).flatMap(_.projectRefs).flatMap(projectId) +lazy val loomProjects: Seq[String] = Seq( + nettyServerSync, + nimaServer, + examples, + documentation, + opentelemetryTracingSync +).flatMap(_.projectRefs).flatMap(projectId) def projectId(projectRef: ProjectReference): Option[String] = projectRef match { @@ -264,6 +271,23 @@ def projectId(projectRef: ProjectReference): Option[String] = case LocalProject(id) => Some(id) case _ => None } +lazy val opentelemetryTracingSync: ProjectMatrix = (projectMatrix in file("tracing/opentelemetry-tracing-sync")) + .settings(commonSettings) + .settings( + name := "tapir-opentelemetry-tracing-sync", + libraryDependencies ++= Seq( + "io.opentelemetry" % "opentelemetry-api" % Versions.openTelemetry, + "io.opentelemetry" % "opentelemetry-context" % Versions.openTelemetryContext, + "io.opentelemetry" % "opentelemetry-extension-trace-propagators" % Versions.openTelemetryPropagators, + "io.opentelemetry.semconv" % "opentelemetry-semconv" % Versions.openTelemetrySemconv, + // Test dependencies + "io.opentelemetry" % "opentelemetry-sdk" % Versions.openTelemetrySdk % Test, + "io.opentelemetry" % "opentelemetry-sdk-testing" % Versions.openTelemetrySdk % Test, + scalaTest.value % Test + ) + ) + .jvmPlatform(scalaVersions = scala2_13And3Versions, settings = commonJvmSettings) + .dependsOn(core, serverCore) lazy val allAggregates: Seq[ProjectReference] = { val filteredByNative = if (sys.env.isDefinedAt("STTP_NATIVE")) { @@ -2031,7 +2055,7 @@ lazy val openapiCodegenCli: ProjectMatrix = (projectMatrix in file("openapi-code "org.scala-lang.modules" %% "scala-collection-compat" % Versions.scalaCollectionCompat ) ) - .dependsOn(openapiCodegenCore, core % Test, circeJson % Test) + .dependsOn(openapiCodegenCore, core % Test, circeJson % Test, opentelemetryTracingSync) // other @@ -2040,6 +2064,8 @@ lazy val examples: ProjectMatrix = (projectMatrix in file("examples")) .settings( name := "tapir-examples", libraryDependencies ++= Seq( + "io.opentelemetry" % "opentelemetry-sdk" % Versions.openTelemetry, + "io.opentelemetry" % "opentelemetry-sdk-testing" % Versions.openTelemetry % Test, "com.softwaremill.sttp.apispec" %% "asyncapi-circe-yaml" % Versions.sttpApispec, "com.softwaremill.sttp.client3" %% "core" % Versions.sttp, "com.softwaremill.sttp.client3" %% "pekko-http-backend" % Versions.sttp, diff --git a/doc/server/opentelemetry.md b/doc/server/opentelemetry.md new file mode 100644 index 0000000000..c9b8284116 --- /dev/null +++ b/doc/server/opentelemetry.md @@ -0,0 +1,332 @@ +# OpenTelemetry Tracing for Tapir with Netty Sync Server + +This documentation describes the integration between Tapir and OpenTelemetry for tracing synchronous HTTP requests, optimized for applications using Java's virtual threads (Project Loom). + +## Dependencies + +Add the following dependencies to your `build.sbt` file: + +```scala +libraryDependencies ++= Seq( + "com.softwaremill.sttp.tapir" %% "tapir-netty-server-sync" % "1.11.9", + "com.softwaremill.sttp.tapir" %% "tapir-opentelemetry-tracing-sync" % "1.11.9", + "io.opentelemetry" % "opentelemetry-exporter-otlp" % "1.36.0", + "io.opentelemetry" % "opentelemetry-sdk" % "1.36.0" +) +``` + +## Overview + +This integration provides: +- Synchronous request processing with Netty +- Virtual threads compatibility (Project Loom) +- OpenTelemetry context propagation +- OpenTelemetry baggage handling +- Custom span naming +- HTTP header attributes mapping +- High-performance request handling + +## Additional Netty Server Features +- Graceful shutdown support +- Domain socket support +- WebSocket support through Ox +- Logging through SLF4J (enabled by default) +- Virtual threads optimization +- High-performance request handling + +## Basic Usage + +### OpenTelemetry Configuration + +```scala +import sttp.tapir.* +import sttp.tapir.server.netty.NettySyncServer +import sttp.tapir.server.opentelemetry.* +import io.opentelemetry.api.trace.Tracer + +// Obtain your OpenTelemetry tracer instance +val tracer: Tracer = // ... your OpenTelemetry configuration + +// Create the OpenTelemetry tracing instance +val tracing = new OpenTelemetryTracingSync(tracer) + +// Integrate with server options +val serverOptions = NettySyncServerOptions.customiseInterceptors + .tracingInterceptor(tracing.interceptor()) + .options + +// Create the server with additional Netty configuration +val server = NettySyncServer(serverOptions) + .port(8080) + .host("localhost") +``` + +### Custom OpenTelemetry Configuration + +```scala +val config = OpenTelemetryConfig( + includeHeaders = Set("x-request-id", "user-agent"), // Headers to include as attributes + includeBaggage = true, // Enable baggage propagation + errorPredicate = statusCode => statusCode >= 500, // Define which HTTP status codes are errors + spanNaming = SpanNaming.Path // Choose a span naming strategy +) + +val customTracing = new OpenTelemetryTracingSync(tracer, config) +``` + +### Span Naming Strategies + +Several strategies are available: + +**Default**: Combines HTTP method and path +```scala +val spanNaming = SpanNaming.Default // Example: "GET /users" +``` + +**Path Only**: Uses only the path +```scala +val spanNaming = SpanNaming.Path // Example: "/users" +``` + +**Custom**: Define your own strategy +```scala +val spanNaming = SpanNaming.Custom { endpoint => + s"${endpoint.method.method} - ${endpoint.showPathTemplate()}" +} +``` + +## Complete Examples + +### Basic Server with Tracing + +```scala +import sttp.tapir.* +import sttp.tapir.server.netty.NettySyncServer +import sttp.tapir.server.opentelemetry.* +import io.opentelemetry.api.trace.Tracer +import io.opentelemetry.api.OpenTelemetry +import io.opentelemetry.sdk.OpenTelemetrySdk +import io.opentelemetry.sdk.trace.SdkTracerProvider +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter +import scala.util.Using + +object TracedNettySyncServer: + val healthEndpoint = endpoint.get + .in("health") + .out(stringBody) + .handle { _ => + Right("OK") + } + + def setupTracing(): Tracer = + val spanExporter = OtlpGrpcSpanExporter.builder() + .setEndpoint("http://localhost:4317") + .build() + + val tracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(spanExporter)) + .build() + + val openTelemetry = OpenTelemetrySdk.builder() + .setTracerProvider(tracerProvider) + .build() + + openTelemetry.getTracer("com.example.tapir-server") + + def main(args: Array[String]): Unit = + val tracer = setupTracing() + val tracing = new OpenTelemetryTracingSync(tracer) + + val serverOptions = NettySyncServerOptions + .customiseInterceptors + .tracingInterceptor(tracing.interceptor()) + .options + + val server = NettySyncServer(serverOptions) + .port(8080) + .addEndpoint(healthEndpoint) + + Using.resource(server.start()) { binding => + println("Server running on http://localhost:8080") + Thread.sleep(Long.MaxValue) + } +``` + +### WebSocket Server + +```scala +import ox.* + +val wsEndpoint = endpoint.get + .in("ws") + .out(webSocketBody[String, CodecFormat.TextPlain, String, CodecFormat.TextPlain]) + +def wsLogic(using Ox): Source[String] => Source[String] = input => + input.map(_.toUpperCase) + +val wsServerEndpoint = wsEndpoint.handle(wsLogic) + +// Add to server +val server = NettySyncServer(serverOptions) + .addEndpoint(wsServerEndpoint) +``` + +### Domain Socket Server + +```scala +import java.nio.file.Paths +import io.netty.channel.unix.DomainSocketAddress + +val binding = NettySyncServer() + .addEndpoint(endpoint) + .startUsingDomainSocket(Paths.get("/tmp/server.sock")) +``` + +## Configuration Options + +### OpenTelemetryConfig + +| Option | Type | Default | Description | +| ---------------- | --------------------- | ----------------------- | --------------------------------------------- | +| `includeHeaders` | `Set[String]` | `Set.empty` | HTTP headers to include as attributes | +| `includeBaggage` | `Boolean` | `true` | Enable/disable baggage propagation | +| `errorPredicate` | `Int => Boolean` | `_ >= 500` | Determines which HTTP status codes are errors | +| `spanNaming` | `SpanNaming` | `Default` | Strategy for naming spans | +| `virtualThreads` | `VirtualThreadConfig` | `VirtualThreadConfig()` | Configuration for virtual threads | + +### VirtualThreadConfig + +| Option | Type | Default | Description | +| ------------------------- | --------- | ------------- | ------------------------------------ | +| `useVirtualThreads` | `Boolean` | `true` | Enable/disable virtual threads usage | +| `virtualThreadNamePrefix` | `String` | `"tapir-ot-"` | Prefix for virtual thread names | + +### Netty Server Configuration + +```scala +import scala.concurrent.duration.* + +// Basic configuration +val server = NettySyncServer() + .port(8080) + .host("localhost") + .withGracefulShutdownTimeout(5.seconds) + +// Advanced Netty configuration +val nettyConfig = NettyConfig.default + .socketBacklog(256) + .withGracefulShutdownTimeout(5.seconds) + // Or disable graceful shutdown + //.noGracefulShutdown + +val serverWithConfig = NettySyncServer(nettyConfig) +``` + +## Testing + +For testing your application with tracing: + +```scala +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter +import io.opentelemetry.sdk.trace.SdkTracerProvider +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor + +val spanExporter = InMemorySpanExporter.create() +val tracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(spanExporter)) + .build() +val tracer = tracerProvider.get("test-tracer") + +// After running your test +val spans = spanExporter.getFinishedSpanItems() +// Perform assertions on spans +``` + +## Virtual Threads Compatibility + +This module is optimized for use with Project Loom's virtual threads: +- Uses `ScopedValue` instead of `ThreadLocal` for context storage +- Ensures tracing context is maintained across thread boundaries +- Efficiently handles a large number of concurrent requests +- Proper context propagation across virtual threads + +## Performance and Optimization + +- Minimal overhead for synchronous operations +- Efficient context propagation +- Non-blocking operations in the request processing path +- Thread-safe for highly concurrent environments +- Optimized for virtual threads via Netty Sync +- Configurable socket backlog and other Netty parameters +- Graceful shutdown support for clean request handling + +## Debugging + +Spans include standard HTTP attributes: +- `http.method` +- `http.url` +- `http.status_code` +- Custom headers (if configured) +- Error information + +To view spans: +1. Use an OpenTelemetry-compatible tracing backend (Jaeger, Zipkin) +2. Configure the OpenTelemetry SDK to export spans +3. Use the backend's UI to visualize and inspect spans + +### Logging +By default, logging of handled requests and exceptions is enabled using SLF4J. You can customize it: + +```scala +val serverOptions = NettySyncServerOptions.customiseInterceptors + .serverLog(None) // Disable logging + .options +``` + +## Best Practices + +1. **Span Naming** + - Use descriptive and consistent names + - Include HTTP method and path + - Avoid overly generic names + - Consider using custom naming for specific use cases + +2. **Attributes** + - Limit traced headers to relevant ones + - Add meaningful business attributes + - Follow OpenTelemetry semantic conventions + - Consider performance impact of attribute collection + +3. **Error Handling** + - Configure error predicate appropriately + - Add relevant details to error spans + - Use span events for exceptions + - Consider error handling in WebSocket scenarios + +4. **Performance** + - Monitor tracing impact + - Use sampling if needed + - Optimize configuration for your use case + - Consider using domain sockets for local communication + - Configure appropriate shutdown timeouts + - Tune Netty parameters for your load + +## Integration with Other Tapir Components + +The OpenTelemetry Sync module works seamlessly with: +- Security interceptors +- Documentation generators +- Other monitoring solutions +- Server endpoints and routing +- WebSocket endpoints +- Domain socket endpoints + +## Limitations + +- Requires Java 19+ for virtual threads +- Ensure other libraries are virtual thread compatible +- Context propagation may need manual handling in complex threading scenarios +- Sampling might be required for high-throughput applications +- WebSocket support requires understanding of Ox concurrency model +- Domain socket support limited to Unix-like systems diff --git a/project/Versions.scala b/project/Versions.scala index 9fb730b1ac..2826122851 100644 --- a/project/Versions.scala +++ b/project/Versions.scala @@ -67,4 +67,9 @@ object Versions { val logback = "1.5.12" val slf4j = "2.0.16" val jsoniter = "2.31.3" + val openTelemetry = "1.36.0" + val openTelemetrySdk = "1.36.0" + val openTelemetryContext = "1.36.0" + val openTelemetryPropagators = "1.36.0" + val openTelemetrySemconv = "1.23.1-alpha" } diff --git a/tracing/opentelemetry-tracing-sync/src/main/scala/sttp/tapir/server/opentelemetry/OpenTelemetryConfig.scala b/tracing/opentelemetry-tracing-sync/src/main/scala/sttp/tapir/server/opentelemetry/OpenTelemetryConfig.scala new file mode 100644 index 0000000000..834614b485 --- /dev/null +++ b/tracing/opentelemetry-tracing-sync/src/main/scala/sttp/tapir/server/opentelemetry/OpenTelemetryConfig.scala @@ -0,0 +1,16 @@ +package sttp.tapir.server.opentelemetry + +/** + * Configuration options for OpenTelemetry tracing + * + * @param includeHeaders Headers to include as span attributes + * @param includeBaggage Whether to include OpenTelemetry baggage in spans + * @param errorPredicate Custom predicate to determine if a response should be marked as error + * @param spanNaming Strategy for naming spans + */ +case class OpenTelemetryConfig( + includeHeaders: Set[String] = Set.empty, + includeBaggage: Boolean = true, + errorPredicate: Int => Boolean = _ >= 500, + spanNaming: SpanNaming = SpanNaming.Default +) diff --git a/tracing/opentelemetry-tracing-sync/src/main/scala/sttp/tapir/server/opentelemetry/OpenTelemetryTracingSync.scala b/tracing/opentelemetry-tracing-sync/src/main/scala/sttp/tapir/server/opentelemetry/OpenTelemetryTracingSync.scala new file mode 100644 index 0000000000..48eae29b58 --- /dev/null +++ b/tracing/opentelemetry-tracing-sync/src/main/scala/sttp/tapir/server/opentelemetry/OpenTelemetryTracingSync.scala @@ -0,0 +1,131 @@ +package sttp.tapir.server.opentelemetry + +import io.opentelemetry.api.trace.{Span, SpanKind, StatusCode, Tracer} +import io.opentelemetry.context.{Context, ContextKey} +import io.opentelemetry.api.baggage.Baggage +import io.opentelemetry.context.propagation.{TextMapGetter, TextMapPropagator} +import sttp.tapir.model.ServerRequest +import sttp.tapir.server.interceptor.{EndpointInterceptor, RequestResult, SecureEndpointInterceptor} +import scala.util.control.NonFatal +import scala.jdk.CollectionConverters._ + +/** + * OpenTelemetry tracing implementation for synchronous/direct-style endpoints. + * Designed to be compatible with virtual threads (Project Loom). + * + * @param tracer OpenTelemetry tracer instance + * @param propagator Context propagator (defaults to W3C) + * @param config Tracing configuration + */ +class OpenTelemetryTracingSync( + tracer: Tracer, + propagator: TextMapPropagator, + config: OpenTelemetryConfig = OpenTelemetryConfig() +) extends SecureEndpointInterceptor[Identity] { + + private val SERVER_SPAN_KEY = ContextKey.named("tapir-server-span") + + private val textMapGetter = new TextMapGetter[ServerRequest] { + override def keys(carrier: ServerRequest): java.lang.Iterable[String] = + carrier.headers.map(_.name).asJava + + override def get(carrier: ServerRequest, key: String): String = + carrier.header(key).orNull + } + + private def executeInVirtualThread[A](f: => A): A = { + Thread.ofVirtual() + .name(s"tapir-ot-${Thread.currentThread().getName}") + .start(() => f) + .join() + } + + def apply[A]( + endpoint: Endpoint[_, _, _, _, _], + securityLogic: EndpointInterceptor[Identity], + delegate: EndpointInterceptor[Identity] + ): EndpointInterceptor[Identity] = + new EndpointInterceptor[Identity] { + def apply(request: ServerRequest): RequestResult[Identity] = { + executeInVirtualThread { + val parentContext = propagator.extract(Context.current(), request, textMapGetter) + + val spanBuilder = tracer + .spanBuilder(getSpanName(endpoint)) + .setParent(parentContext) + .setSpanKind(SpanKind.SERVER) + + addRequestAttributes(spanBuilder, request) + + val span = spanBuilder.startSpan() + try { + val scopedContext = parentContext.`with`(SERVER_SPAN_KEY, span) + Context.makeContext(scopedContext) + + if (config.includeBaggage) { + addBaggageToSpan(span, Baggage.current()) + } + + val result = try { + delegate(request) + } catch { + case NonFatal(e) => + span.recordException(e) + span.setStatus(StatusCode.ERROR) + throw e + } + + handleResult(result, span) + result + } finally { + span.end() + } + } + } + } + + private def getSpanName(endpoint: Endpoint[_, _, _, _, _]): String = + config.spanNaming match { + case SpanNaming.Default => endpoint.showShort + case SpanNaming.Path => endpoint.showPath + case SpanNaming.Custom(f) => f(endpoint) + } + + private def addRequestAttributes(spanBuilder: SpanBuilder, request: ServerRequest): Unit = { + spanBuilder + .setAttribute("http.method", request.method.method) + .setAttribute("http.url", request.uri.toString) + + config.includeHeaders.foreach { headerName => + request.header(headerName).foreach { value => + spanBuilder.setAttribute(s"http.header.$headerName", value) + } + } + } + + private def addBaggageToSpan(span: Span, baggage: Baggage): Unit = { + baggage.asMap.asScala.foreach { case (key, entry) => + span.setAttribute(s"baggage.$key", entry.getValue) + } + } + + private def handleResult(result: RequestResult[Identity], span: Span): Unit = { + result match { + case RequestResult.Response(response) => + val statusCode = response.code.code + span.setAttribute("http.status_code", statusCode) + + if (config.errorPredicate(statusCode)) { + span.setStatus(StatusCode.ERROR) + } else { + span.setStatus(StatusCode.OK) + } + + case RequestResult.Failure(e) => + span.setStatus(StatusCode.ERROR) + span.recordException(e) + } + } + + def currentSpan(): Option[Span] = Option(Context.current().get(SERVER_SPAN_KEY)) +} diff --git a/tracing/opentelemetry-tracing-sync/src/main/scala/sttp/tapir/server/opentelemetry/SpanNaming.scala b/tracing/opentelemetry-tracing-sync/src/main/scala/sttp/tapir/server/opentelemetry/SpanNaming.scala new file mode 100644 index 0000000000..e25d1fd6a4 --- /dev/null +++ b/tracing/opentelemetry-tracing-sync/src/main/scala/sttp/tapir/server/opentelemetry/SpanNaming.scala @@ -0,0 +1,15 @@ +package sttp.tapir.server.opentelemetry + +import sttp.tapir.Endpoint + +sealed trait SpanNaming +object SpanNaming { + /** Utilise le format par défaut : "METHOD /path" */ + case object Default extends SpanNaming + + /** Utilise uniquement le chemin de l'endpoint */ + case object Path extends SpanNaming + + /** Permet une personnalisation complète du nommage des spans */ + case class Custom(f: Endpoint[_, _, _, _, _] => String) extends SpanNaming +} \ No newline at end of file diff --git a/tracing/opentelemetry-tracing-sync/src/main/scala/sttp/tapir/server/opentelemetry/package.scala b/tracing/opentelemetry-tracing-sync/src/main/scala/sttp/tapir/server/opentelemetry/package.scala new file mode 100644 index 0000000000..f5336724eb --- /dev/null +++ b/tracing/opentelemetry-tracing-sync/src/main/scala/sttp/tapir/server/opentelemetry/package.scala @@ -0,0 +1,7 @@ +package sttp.tapir.server + +import sttp.shared.Identity + +package object opentelemetry { + // Identity type alias removed as it's already available in sttp.shared +} diff --git a/tracing/opentelemetry-tracing-sync/src/test/scala/sttp/tapir/server/opentelemetry/OpenTelemetryTracingSyncTest.scala b/tracing/opentelemetry-tracing-sync/src/test/scala/sttp/tapir/server/opentelemetry/OpenTelemetryTracingSyncTest.scala new file mode 100644 index 0000000000..60e127a8c6 --- /dev/null +++ b/tracing/opentelemetry-tracing-sync/src/test/scala/sttp/tapir/server/opentelemetry/OpenTelemetryTracingSyncTest.scala @@ -0,0 +1,112 @@ +package sttp.tapir.server.opentelemetry + +import io.opentelemetry.api.trace.{Span, SpanKind, StatusCode, Tracer} +import io.opentelemetry.sdk.trace.SdkTracerProvider +import io.opentelemetry.sdk.testing.trace.InMemorySpanExporter +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.tapir._ +import sttp.model.Headers +import sttp.model.Header +import sttp.model.StatusCode._ +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor + +class OpenTelemetryTracingSyncTest extends AnyFlatSpec with Matchers { + + val spanExporter = InMemorySpanExporter.create() + val tracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(spanExporter)) + .build() + val tracer: Tracer = tracerProvider.get("test") + val propagator = W3CTraceContextPropagator.getInstance() + + override def beforeEach(): Unit = { + spanExporter.reset() + } + + it should "propagate tracing context" in { + val tracing = new OpenTelemetryTracingSync(tracer, propagator) + + val traceparent = "00-0af7651916cd43dd8448eb211c80319c-b9c7c989f97918e1-01" + val request = ServerRequest( + method = Method.GET, + uri = uri"http://test.com/test", + headers = List(Header("traceparent", traceparent)) + ) + + tracing(endpoint.get.in("test"), _ => RequestResult.Success(()), _ => RequestResult.Response("OK"))(request) + + val spans = spanExporter.getFinishedSpanItems + val span = spans.get(0) + + span.getParentSpanContext.getTraceId should be ("0af7651916cd43dd8448eb211c80319c") + } + + it should "handle errors correctly" in { + val tracing = new OpenTelemetryTracingSync(tracer, propagator) + val exception = new RuntimeException("test error") + + val request = ServerRequest( + method = Method.GET, + uri = uri"http://test.com/test", + headers = List.empty + ) + + tracing( + endpoint.get.in("test"), + _ => RequestResult.Success(()), + _ => RequestResult.Failure(exception) + )(request) + + val spans = spanExporter.getFinishedSpanItems + val span = spans.get(0) + + span.getStatus.isError should be (true) + span.getEvents.size should be (1) + } + + it should "include configured headers as span attributes" in { + val config = OpenTelemetryConfig( + includeHeaders = Set("user-agent", "x-request-id") + ) + val tracing = new OpenTelemetryTracingSync(tracer, propagator, config) + + val request = ServerRequest( + method = Method.GET, + uri = uri"http://test.com/test", + headers = List( + Header("user-agent", "test-agent"), + Header("x-request-id", "123") + ) + ) + + tracing(endpoint.get.in("test"), _ => RequestResult.Success(()), _ => RequestResult.Response("OK"))(request) + + val spans = spanExporter.getFinishedSpanItems + val span = spans.get(0) + + span.getAttributes.get("http.header.user-agent").getStringValue should be ("test-agent") + span.getAttributes.get("http.header.x-request-id").getStringValue should be ("123") + } + + it should "support custom span naming" in { + val config = OpenTelemetryConfig( + spanNaming = SpanNaming.Custom(e => s"CUSTOM-${e.showShort}") + ) + val tracing = new OpenTelemetryTracingSync(tracer, propagator, config) + + val request = ServerRequest( + method = Method.GET, + uri = uri"http://test.com/test", + headers = List.empty + ) + + tracing(endpoint.get.in("test"), _ => RequestResult.Success(()), _ => RequestResult.Response("OK"))(request) + + val spans = spanExporter.getFinishedSpanItems + val span = spans.get(0) + + span.getName should startWith ("CUSTOM-") + } +} \ No newline at end of file