diff --git a/docs/sdk/aws-xray-propagator.md b/docs/sdk/aws-xray-propagator.md index b02f9361b..70cdce394 100644 --- a/docs/sdk/aws-xray-propagator.md +++ b/docs/sdk/aws-xray-propagator.md @@ -64,7 +64,7 @@ object TelemetryApp extends IOApp.Simple { .autoConfigured[IO]( // register OTLP exporters configurer _.addExportersConfigurer(OtlpExportersAutoConfigure[IO]) - // add AWS X-Ray Propagator propagator + // add AWS X-Ray Propagator .addTracerProviderCustomizer((b, _) => b.addTextMapPropagators(AWSXRayPropagator()) ) @@ -100,7 +100,7 @@ object TelemetryApp extends IOApp.Simple { .autoConfigured[IO]( // register OTLP exporters configurer _.addExporterConfigurer(OtlpSpanExporterAutoConfigure[IO]) - // add AWS X-Ray Propagator propagator + // add AWS X-Ray Propagator .addTracerProviderCustomizer((b, _) => b.addTextMapPropagators(AWSXRayPropagator()) ) @@ -118,4 +118,87 @@ object TelemetryApp extends IOApp.Simple { @:@ -[xray-concepts]: https://docs.aws.amazon.com/xray/latest/devguide/xray-concepts.html#xray-concepts-tracingheader \ No newline at end of file +## AWS Lambda + +AWS Lambda can [utilize][lambda-xray-envvars] `_X_AMZN_TRACE_ID` environment variable or +`com.amazonaws.xray.traceHeader` system property to set the X-Ray tracing header. + +Use `AWSXRayLambdaPropagator` in such a case. + +@:select(sdk-entry-point) + +@:choice(sdk) + +`OpenTelemetrySdk.autoConfigured` configures both `MeterProvider` and `TracerProvider`: + +```scala mdoc:silent:reset +import cats.effect.{IO, IOApp} +import org.typelevel.otel4s.metrics.MeterProvider +import org.typelevel.otel4s.sdk.OpenTelemetrySdk +import org.typelevel.otel4s.sdk.contrib.aws.context.propagation._ +import org.typelevel.otel4s.sdk.exporter.otlp.autoconfigure.OtlpExportersAutoConfigure +import org.typelevel.otel4s.trace.TracerProvider + +object TelemetryApp extends IOApp.Simple { + + def run: IO[Unit] = + OpenTelemetrySdk + .autoConfigured[IO]( + // register OTLP exporters configurer + _.addExportersConfigurer(OtlpExportersAutoConfigure[IO]) + // add AWS X-Ray Lambda Propagator + .addTracerProviderCustomizer((b, _) => + b.addTextMapPropagators(AWSXRayLambdaPropagator()) + ) + ) + .use { autoConfigured => + val sdk = autoConfigured.sdk + program(sdk.meterProvider, sdk.tracerProvider) + } + + def program( + meterProvider: MeterProvider[IO], + tracerProvider: TracerProvider[IO] + ): IO[Unit] = + ??? +} +``` + +@:choice(traces) + +`SdkTraces` configures only `TracerProvider`: + +```scala mdoc:silent:reset +import cats.effect.{IO, IOApp} +import org.typelevel.otel4s.sdk.contrib.aws.context.propagation._ +import org.typelevel.otel4s.sdk.exporter.otlp.trace.autoconfigure.OtlpSpanExporterAutoConfigure +import org.typelevel.otel4s.sdk.trace.SdkTraces +import org.typelevel.otel4s.trace.TracerProvider + +object TelemetryApp extends IOApp.Simple { + + def run: IO[Unit] = + SdkTraces + .autoConfigured[IO]( + // register OTLP exporters configurer + _.addExporterConfigurer(OtlpSpanExporterAutoConfigure[IO]) + // add AWS X-Ray Lambda Propagator + .addTracerProviderCustomizer((b, _) => + b.addTextMapPropagators(AWSXRayLambdaPropagator()) + ) + ) + .use { autoConfigured => + program(autoConfigured.tracerProvider) + } + + def program( + tracerProvider: TracerProvider[IO] + ): IO[Unit] = + ??? +} +``` + +@:@ + +[xray-concepts]: https://docs.aws.amazon.com/xray/latest/devguide/xray-concepts.html#xray-concepts-tracingheader +[lambda-xray-envvars]: https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html \ No newline at end of file diff --git a/sdk-contrib/aws/xray-propagator/src/main/scala/org/typelevel/otel4s/sdk/contrib/aws/context/propagation/AWSXRayLambdaPropagator.scala b/sdk-contrib/aws/xray-propagator/src/main/scala/org/typelevel/otel4s/sdk/contrib/aws/context/propagation/AWSXRayLambdaPropagator.scala new file mode 100644 index 000000000..9950c6d90 --- /dev/null +++ b/sdk-contrib/aws/xray-propagator/src/main/scala/org/typelevel/otel4s/sdk/contrib/aws/context/propagation/AWSXRayLambdaPropagator.scala @@ -0,0 +1,141 @@ +/* + * Copyright 2024 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.typelevel.otel4s.sdk.contrib.aws.context.propagation + +import org.typelevel.otel4s.context.propagation.TextMapGetter +import org.typelevel.otel4s.context.propagation.TextMapPropagator +import org.typelevel.otel4s.context.propagation.TextMapUpdater +import org.typelevel.otel4s.sdk.autoconfigure.AutoConfigure +import org.typelevel.otel4s.sdk.context.Context +import org.typelevel.otel4s.sdk.trace.SdkContextKeys + +/** An example of the AWS X-Ray Tracing Header: + * {{{ + * X-Amzn-Trace-Id: Root=1-5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=1 + * }}} + * + * If the header is missing, the Lambda's `com.amazonaws.xray.traceHeader` + * system property or `_X_AMZN_TRACE_ID` environment variable will be used as a + * fallback. + * + * @see + * [[https://docs.aws.amazon.com/xray/latest/devguide/xray-concepts.html#xray-concepts-tracingheader]] + * + * @see + * [[https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html]] + */ +private final class AWSXRayLambdaPropagator private[propagation] ( + getProp: String => Option[String], + getEnv: String => Option[String] +) extends TextMapPropagator[Context] { + import AWSXRayLambdaPropagator.Const + + private val propagator = AWSXRayPropagator() + + def fields: Iterable[String] = propagator.fields + + def extract[A: TextMapGetter](ctx: Context, carrier: A): Context = { + val xRayContext = propagator.extract(ctx, carrier) + xRayContext.get(SdkContextKeys.SpanContextKey) match { + case Some(_) => + xRayContext + + case None => + val headerOpt = + getProp(Const.TraceHeaderSystemProp) + .orElse(getEnv(Const.TraceHeaderEnvVar)) + + headerOpt match { + case Some(header) => + propagator.extract( + ctx, + Map(AWSXRayPropagator.Headers.TraceId -> header) + ) + + case None => + xRayContext + } + } + } + + def inject[A: TextMapUpdater](ctx: Context, carrier: A): A = + propagator.inject(ctx, carrier) + + override def toString: String = "AWSXRayLambdaPropagator" + +} + +object AWSXRayLambdaPropagator { + private val Propagator = + new AWSXRayLambdaPropagator(sys.props.get, sys.env.get) + + private object Const { + val name = "xray-lambda" + + val TraceHeaderEnvVar = "_X_AMZN_TRACE_ID" + val TraceHeaderSystemProp = "com.amazonaws.xray.traceHeader" + } + + /** Returns an instance of the AWSXRayLambdaPropagator. + * + * The propagator utilizes `X-Amzn-Trace-Id` header to extract and inject + * tracing details. + * + * If the header is missing, the Lambda's `com.amazonaws.xray.traceHeader` + * system property or `_X_AMZN_TRACE_ID` environment variable will be used as + * a fallback. + * + * An example of the AWS X-Ray Tracing Header: + * {{{ + * X-Amzn-Trace-Id: Root=1-5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=1 + * }}} + * + * @example + * {{{ + * OpenTelemetrySdk.autoConfigured[IO]( + * _.addTracerProviderCustomizer((b, _) => b.addTextMapPropagators(AWSXRayLambdaPropagator()) + * ) + * }}} + * @see + * [[https://docs.aws.amazon.com/xray/latest/devguide/xray-concepts.html#xray-concepts-tracingheader]] + */ + def apply(): TextMapPropagator[Context] = Propagator + + /** Returns the named configurer `xray-lambda`. You can use it to dynamically + * enable AWS X-Ray lambda propagator via environment variable or system + * properties. + * + * @example + * {{{ + * OpenTelemetrySdk.autoConfigured[IO]( + * _.addTextMapPropagatorConfigurer(AWSXRayLambdaPropagator.configurer[IO]) + * ) + * }}} + * + * Enable propagator via environment variable: + * {{{ + * OTEL_PROPAGATORS=xray-lambda + * }}} + * or system property: + * {{{ + * -Dotel.propagators=xray-lambda + * }}} + */ + def configurer[F[_]]: AutoConfigure.Named[F, TextMapPropagator[Context]] = + AutoConfigure.Named.const(Const.name, Propagator) + +} diff --git a/sdk-contrib/aws/xray-propagator/src/main/scala/org/typelevel/otel4s/sdk/contrib/aws/context/propagation/AWSXRayPropagator.scala b/sdk-contrib/aws/xray-propagator/src/main/scala/org/typelevel/otel4s/sdk/contrib/aws/context/propagation/AWSXRayPropagator.scala index 143ab6ab3..c398fd08f 100644 --- a/sdk-contrib/aws/xray-propagator/src/main/scala/org/typelevel/otel4s/sdk/contrib/aws/context/propagation/AWSXRayPropagator.scala +++ b/sdk-contrib/aws/xray-propagator/src/main/scala/org/typelevel/otel4s/sdk/contrib/aws/context/propagation/AWSXRayPropagator.scala @@ -28,7 +28,7 @@ import org.typelevel.otel4s.trace.TraceFlags import org.typelevel.otel4s.trace.TraceState import scodec.bits.ByteVector -/** An example of the AWS Xray Tracing Header: +/** An example of the AWS X-Ray Tracing Header: * {{{ * X-Amzn-Trace-Id: Root=1-5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=1 * }}} @@ -156,7 +156,7 @@ private final class AWSXRayPropagator extends TextMapPropagator[Context] { object AWSXRayPropagator { private val Propagator = new AWSXRayPropagator - private object Headers { + private[propagation] object Headers { val TraceId = "X-Amzn-Trace-Id" } @@ -181,7 +181,7 @@ object AWSXRayPropagator { * The propagator utilizes `X-Amzn-Trace-Id` header to extract and inject * tracing details. * - * An example of the AWS Xray Tracing Header: + * An example of the AWS X-Ray Tracing Header: * {{{ * X-Amzn-Trace-Id: Root=1-5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=1 * }}} diff --git a/sdk-contrib/aws/xray-propagator/src/test/scala/org/typelevel/otel4s/sdk/contrib/aws/context/propagation/AWSXRayLambdaPropagatorSuite.scala b/sdk-contrib/aws/xray-propagator/src/test/scala/org/typelevel/otel4s/sdk/contrib/aws/context/propagation/AWSXRayLambdaPropagatorSuite.scala new file mode 100644 index 000000000..914abef37 --- /dev/null +++ b/sdk-contrib/aws/xray-propagator/src/test/scala/org/typelevel/otel4s/sdk/contrib/aws/context/propagation/AWSXRayLambdaPropagatorSuite.scala @@ -0,0 +1,196 @@ +/* + * Copyright 2024 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.typelevel.otel4s.sdk.contrib.aws.context.propagation + +import munit.ScalaCheckSuite +import org.scalacheck.Prop +import org.typelevel.otel4s.sdk.context.Context +import org.typelevel.otel4s.sdk.trace.SdkContextKeys +import org.typelevel.otel4s.sdk.trace.scalacheck.Gens +import org.typelevel.otel4s.trace.SpanContext + +class AWSXRayLambdaPropagatorSuite extends ScalaCheckSuite { + + private val propagator = AWSXRayLambdaPropagator() + + // + // Common + // + + test("fields") { + assertEquals(propagator.fields, List("X-Amzn-Trace-Id")) + } + + test("toString") { + assertEquals(propagator.toString, "AWSXRayLambdaPropagator") + } + + // + // Inject + // + + test("inject nothing when context is empty") { + val result = propagator.inject(Context.root, Map.empty[String, String]) + assertEquals(result.size, 0) + } + + test("inject - invalid context - do nothing") { + val ctx = SpanContext.invalid + val context = Context.root.updated(SdkContextKeys.SpanContextKey, ctx) + + assertEquals( + propagator.inject(context, Map.empty[String, String]), + Map.empty[String, String] + ) + } + + test("inject - valid context") { + Prop.forAll(Gens.spanContext) { ctx => + val context = Context.root.updated(SdkContextKeys.SpanContextKey, ctx) + val expected = toTraceId(ctx) + val result = propagator.inject(context, Map.empty[String, String]) + + assertEquals(result.get("X-Amzn-Trace-Id"), Some(expected)) + } + } + + // + // Extract (common) + // + + test("extract - empty context") { + val ctx = propagator.extract(Context.root, Map.empty[String, String]) + assertEquals(getSpanContext(ctx), None) + } + + test("extract - 'X-Amzn-Trace-Id' header is missing") { + val ctx = propagator.extract(Context.root, Map("key" -> "value")) + assertEquals(getSpanContext(ctx), None) + } + + test("extract - valid 'X-Amzn-Trace-Id' header") { + Prop.forAll(Gens.spanContext) { ctx => + val carrier = Map("X-Amzn-Trace-Id" -> toTraceId(ctx)) + val result = propagator.extract(Context.root, carrier) + assertEquals(getSpanContext(result), Some(asRemote(ctx))) + } + } + + test("extract - 'X-Amzn-Trace-Id' header has invalid format") { + val carrier = Map("X-Amzn-Trace-Id" -> "Root=00-11-22-33") + val result = propagator.extract(Context.root, carrier) + assertEquals(getSpanContext(result), None) + } + + test("extract - invalid header - missing values") { + val carrier = Map("X-Amzn-Trace-Id" -> "Root=;Parent=;Sampled=") + val result = propagator.extract(Context.root, carrier) + assertEquals(getSpanContext(result), None) + } + + test("extract - invalid Root format - missing version") { + val carrier = Map( + "X-Amzn-Trace-Id" -> "Root=5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=1" + ) + val result = propagator.extract(Context.root, carrier) + assertEquals(getSpanContext(result), None) + } + + test("extract - invalid Root format - no separator") { + Prop.forAll(Gens.spanContext) { ctx => + val sampled = if (ctx.isSampled) "1" else "0" + val carrier = Map( + "X-Amzn-Trace-Id" -> s"Root=1-${ctx.traceIdHex};Parent=${ctx.spanIdHex};Sampled=$sampled" + ) + val result = propagator.extract(Context.root, carrier) + assertEquals(getSpanContext(result), None) + } + } + + test("extract - invalid span id (too long)") { + Prop.forAll(Gens.spanContext) { ctx => + val sampled = if (ctx.isSampled) "1" else "0" + val traceId = s"${ctx.traceIdHex}:${ctx.spanIdHex}00:0:$sampled" + val carrier = Map("X-Amzn-Trace-Id" -> traceId) + val result = propagator.extract(Context.root, carrier) + assertEquals(getSpanContext(result), None) + } + } + + test("extract - invalid flags (too long)") { + Prop.forAll(Gens.spanContext) { ctx => + val carrier = Map( + "X-Amzn-Trace-Id" -> s"Root=1-${ctx.traceIdHex.take(8)}-${ctx.traceIdHex.drop(8)};Parent=${ctx.spanIdHex};Sampled=123" + ) + val result = propagator.extract(Context.root, carrier) + assertEquals(getSpanContext(result), None) + } + } + + // + // Extract (lambda-specific scenarios) + // + + test("extract - use env variable when input is empty") { + Prop.forAll(Gens.spanContext) { ctx => + val props = Map.empty[String, String] + val env = Map("_X_AMZN_TRACE_ID" -> toTraceId(ctx)) + val propagator = new AWSXRayLambdaPropagator(props.get, env.get) + val result = propagator.extract(Context.root, Map.empty[String, String]) + assertEquals(getSpanContext(result), Some(asRemote(ctx))) + } + } + + test("extract - use sys prop when input is empty") { + Prop.forAll(Gens.spanContext) { ctx => + val props = Map("com.amazonaws.xray.traceHeader" -> toTraceId(ctx)) + val env = Map.empty[String, String] + val propagator = new AWSXRayLambdaPropagator(props.get, env.get) + val result = propagator.extract(Context.root, Map.empty[String, String]) + assertEquals(getSpanContext(result), Some(asRemote(ctx))) + } + } + + test("extract - prioritize sys prop over env variable when input is empty") { + Prop.forAll(Gens.spanContext, Gens.spanContext) { (ctx1, ctx2) => + val props = Map("com.amazonaws.xray.traceHeader" -> toTraceId(ctx1)) + val env = Map("_X_AMZN_TRACE_ID" -> toTraceId(ctx2)) + val propagator = new AWSXRayLambdaPropagator(props.get, env.get) + val result = propagator.extract(Context.root, Map.empty[String, String]) + assertEquals(getSpanContext(result), Some(asRemote(ctx1))) + } + } + + private def toTraceId(ctx: SpanContext): String = { + val sampled = if (ctx.isSampled) "1" else "0" + val traceId = ctx.traceIdHex.take(8) + "-" + ctx.traceIdHex.drop(8) + s"Root=1-$traceId;Parent=${ctx.spanIdHex};Sampled=$sampled" + } + + private def getSpanContext(ctx: Context): Option[SpanContext] = + ctx.get(SdkContextKeys.SpanContextKey) + + private def asRemote(ctx: SpanContext): SpanContext = + SpanContext( + traceId = ctx.traceId, + spanId = ctx.spanId, + traceFlags = ctx.traceFlags, + traceState = ctx.traceState, + remote = true + ) + +}