diff --git a/docs/sdk/aws-resource-detectors.md b/docs/sdk/aws-resource-detectors.md index 3405838d4..6c47a78f3 100644 --- a/docs/sdk/aws-resource-detectors.md +++ b/docs/sdk/aws-resource-detectors.md @@ -36,7 +36,7 @@ envEntries.foreach { case (k, v) => println(s"${k.replace("_", "_")}=$v") } println("```") println("Detected resource: ") -println("```") +println("```yaml") AWSLambdaDetector[IO].detect.unsafeRunSync().foreach { resource => resource.attributes.toList.sortBy(_.key.name).foreach { attribute => println(attribute.key.name + ": " + attribute.value) @@ -45,7 +45,7 @@ AWSLambdaDetector[IO].detect.unsafeRunSync().foreach { resource => println("```") ``` -### 3. aws-ec2 +### 2. aws-ec2 The detector fetches instance metadata from the `http://169.254.169.254` endpoint. See [AWS documentation](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-data-retrieval.html) for more @@ -97,7 +97,7 @@ println(hostname) println("```") println("Detected resource: ") -println("```") +println("```yaml") AWSEC2Detector[IO](uri"", client).detect.unsafeRunSync().foreach { resource => resource.attributes.toList.sortBy(_.key.name).foreach { attribute => println(attribute.key.name + ": " + attribute.value) @@ -106,6 +106,108 @@ AWSEC2Detector[IO](uri"", client).detect.unsafeRunSync().foreach { resource => println("```") ``` +### 3. aws-ecs + +The detector fetches ECS container and task metadata. +The base URI is obtained from `ECS_CONTAINER_METADATA_URI_V4` or `ECS_CONTAINER_METADATA_URI` env variable. + +See [AWS documentation](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-metadata-endpoint-v4.html) for more +details. + +```scala mdoc:reset:passthrough +import cats.effect.IO +import cats.effect.std.Env +import cats.effect.unsafe.implicits.global +import io.circe.Json +import io.circe.syntax._ +import org.http4s._ +import org.http4s.circe.jsonEncoder +import org.http4s.client.Client +import org.http4s.dsl.io._ +import org.typelevel.otel4s.sdk.contrib.aws.resource._ +import scala.collection.immutable + +val envEntries = Map( + "ECS_CONTAINER_METADATA_URI_V4" -> "http://169.254.170.2/v4/5fb8fcdd-29f2-490f-8229-c1269d11a9d9" +) + +implicit val env: Env[IO] = new Env[IO] { + def get(name: String): IO[Option[String]] = IO.pure(envEntries.get(name)) + def entries: IO[immutable.Iterable[(String, String)]] = IO.pure(envEntries) +} + +val accountId = "1234567890" +val region = "eu-west-1" +val taskId = "5e1b...86980" +val family = "service-production" +val cluster = "production" +val revision = "11" +val taskArn = s"arn:aws:ecs:$region:$accountId:task/$cluster/$taskId" + +val container = Json.obj( + "DockerId" := "83b2af5973dc...ee1e1", + "Name" := "server", + "DockerName" := s"ecs-$family-$revision-server-e4e7efbceda7b7c68601", + "Image" := s"$accountId.dkr.ecr.$region.amazonaws.com/internal/repository:8abab2a5", + "ImageID" := "sha256:7382b7779e6038...11f2d7d522d", + "DesiredStatus" := "RUNNING", + "CreatedAt" := "2024-09-12T18:08:55.593944224Z", + "StartedAt" := "2024-09-12T18:08:56.524454503Z", + "Type" := "NORMAL", + "Health" := Json.obj("status" := "HEALTHY"), + "LogDriver" := "awslogs", + "LogOptions" := Json.obj( + "awslogs-group" := s"/ecs/$cluster/service", + "awslogs-region" := region, + "awslogs-stream" := s"ecs/server/$taskId" + ), + "ContainerARN" := s"$taskArn/1a1c23fe-1718-4eed-9833-c3dc2dad712c" +) + +val task = Json.obj( + "Cluster" := cluster, + "TaskARN" := taskArn, + "Family" := family, + "Revision" := revision, + "DesiredStatus" := "RUNNING", + "KnownStatus" := "RUNNING", + "PullStartedAt" := "2024-09-12T18:08:55.307387715Z", + "PullStoppedAt" := "2024-09-12T18:08:55.564707417Z", + "AvailabilityZone" := "eu-west-1a", + "LaunchType" := "EC2", + "VPCID" := "vpc-123", + "ServiceName" := "service" +) + +val client = Client.fromHttpApp[IO]( + HttpRoutes + .of[IO] { + case GET -> Root / "v4" / "5fb8fcdd-29f2-490f-8229-c1269d11a9d9" => Ok(container) + case GET -> Root / "v4" / "5fb8fcdd-29f2-490f-8229-c1269d11a9d9" / "task" => Ok(task) + } + .orNotFound +) + +println("The `http://169.254.170.2/v4/5fb8fcdd-29f2-490f-8229-c1269d11a9d9` response: ") +println("```json") +println(container) +println("```") + +println("The `http://169.254.170.2/v4/5fb8fcdd-29f2-490f-8229-c1269d11a9d9/task` response:") +println("```json") +println(task) +println("```") + +println("Detected resource: ") +println("```yaml") +AWSECSDetector[IO](client).detect.unsafeRunSync().foreach { resource => + resource.attributes.toList.sortBy(_.key.name).foreach { attribute => + println(attribute.key.name + ": " + attribute.value) + } +} +println("```") +``` + ## Getting Started @:select(build-tool) @@ -167,6 +269,8 @@ object TelemetryApp extends IOApp.Simple { .addResourceDetector(AWSLambdaDetector[IO]) // register AWS EC2 detector .addResourceDetector(AWSEC2Detector[IO]) + // register AWS ECS detector + .addResourceDetector(AWSECSDetector[IO]) ) .use { autoConfigured => val sdk = autoConfigured.sdk @@ -203,6 +307,8 @@ object TelemetryApp extends IOApp.Simple { .addResourceDetector(AWSLambdaDetector[IO]) // register AWS EC2 detector .addResourceDetector(AWSEC2Detector[IO]) + // register AWS ECS detector + .addResourceDetector(AWSECSDetector[IO]) ) .use { autoConfigured => program(autoConfigured.tracerProvider) @@ -231,8 +337,8 @@ There are several ways to configure the options: Add settings to the `build.sbt`: ```scala -javaOptions += "-Dotel.otel4s.resource.detectors.enabled=aws-lambda,aws-ec2" -envVars ++= Map("OTEL_OTEL4S_RESOURCE_DETECTORS_ENABLE" -> "aws-lambda,aws-ec2") +javaOptions += "-Dotel.otel4s.resource.detectors.enabled=aws-lambda,aws-ec2,aws-ecs" +envVars ++= Map("OTEL_OTEL4S_RESOURCE_DETECTORS_ENABLE" -> "aws-lambda,aws-ec2,aws-ecs") ``` @:choice(scala-cli) @@ -240,12 +346,12 @@ envVars ++= Map("OTEL_OTEL4S_RESOURCE_DETECTORS_ENABLE" -> "aws-lambda,aws-ec2") Add directives to the `*.scala` file: ```scala -//> using javaOpt -Dotel.otel4s.resource.detectors.enabled=aws-lambda,aws-ec2 +//> using javaOpt -Dotel.otel4s.resource.detectors.enabled=aws-lambda,aws-ec2,aws-ecs ``` @:choice(shell) ```shell -$ export OTEL_OTEL4S_RESOURCE_DETECTORS_ENABLED=aws-lambda,aws-ec2 +$ export OTEL_OTEL4S_RESOURCE_DETECTORS_ENABLED=aws-lambda,aws-ec2,aws-ecs ``` @:@ diff --git a/sdk-contrib/aws/resource/src/main/scala/org/typelevel/otel4s/sdk/contrib/aws/resource/AWSECSDetector.scala b/sdk-contrib/aws/resource/src/main/scala/org/typelevel/otel4s/sdk/contrib/aws/resource/AWSECSDetector.scala new file mode 100644 index 000000000..d9230b90a --- /dev/null +++ b/sdk-contrib/aws/resource/src/main/scala/org/typelevel/otel4s/sdk/contrib/aws/resource/AWSECSDetector.scala @@ -0,0 +1,303 @@ +/* + * 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.resource + +import cats.effect.Async +import cats.effect.Resource +import cats.effect.std.Console +import cats.effect.std.Env +import cats.syntax.applicativeError._ +import cats.syntax.apply._ +import cats.syntax.flatMap._ +import cats.syntax.functor._ +import fs2.io.net.Network +import io.circe.Decoder +import org.http4s.EntityDecoder +import org.http4s.circe._ +import org.http4s.client.Client +import org.http4s.ember.client.EmberClientBuilder +import org.typelevel.otel4s.AttributeKey +import org.typelevel.otel4s.Attributes +import org.typelevel.otel4s.sdk.TelemetryResource +import org.typelevel.otel4s.sdk.resource.TelemetryResourceDetector +import org.typelevel.otel4s.semconv.SchemaUrls + +import scala.concurrent.duration._ + +private class AWSECSDetector[F[_]: Async: Network: Env: Console] private ( + customClient: Option[Client[F]] +) extends TelemetryResourceDetector[F] { + + import AWSECSDetector.Const + import AWSECSDetector.Keys + import AWSECSDetector.ContainerMetadata + import AWSECSDetector.TaskMetadata + + private implicit val containerDecoder: EntityDecoder[F, ContainerMetadata] = accumulatingJsonOf + private implicit val taskDecoder: EntityDecoder[F, TaskMetadata] = accumulatingJsonOf + + def name: String = Const.Name + + def detect: F[Option[TelemetryResource]] = + (Env[F].get(Const.MetadataV4Key), Env[F].get(Const.MetadataV3Key)).flatMapN { (v4, v3) => + v4.orElse(v3) match { + case Some(uri) => + mkClient.use { client => + retrieve(client, uri).handleErrorWith { e => + Console[F] + .errorln(s"AWSECSDetector: cannot retrieve metadata from $uri due to ${e.getMessage}") + .as(None) + } + } + + case None => + Async[F].pure(None) + } + } + + private def retrieve(client: Client[F], uri: String): F[Option[TelemetryResource]] = + for { + container <- client.expect[ContainerMetadata](uri) + task <- client.expect[TaskMetadata](uri + "/task") + } yield Some(build(container, task)) + + private def build(container: ContainerMetadata, task: TaskMetadata): TelemetryResource = { + val builder = Attributes.newBuilder + + val (regionOpt, accountIdOpt) = parseAccountAndRegion(task.taskArn) + val (imageNameOpt, imageTagOpt) = parseDockerImage(container.image) + + // cloud + builder.addOne(Keys.CloudProvider, Const.CloudProvider) + builder.addOne(Keys.CloudPlatform, Const.CloudPlatform) + builder.addOne(Keys.CloudResourceId, container.containerArn) + builder.addOne(Keys.CloudAvailabilityZones, task.availabilityZone) + regionOpt.foreach(region => builder.addOne(Keys.CloudRegion, region)) + accountIdOpt.foreach(accountId => builder.addOne(Keys.CloudAccountId, accountId)) + + // container + builder.addOne(Keys.ContainerId, container.dockerId) + builder.addOne(Keys.ContainerName, container.dockerName) + imageNameOpt.foreach(name => builder.addOne(Keys.ContainerImageName, name)) + imageTagOpt.foreach(tag => builder.addOne(Keys.ContainerImageTags, Seq(tag))) + + // aws + builder.addOne(Keys.AwsLogGroupNames, Seq(container.logOptions.group)) + builder.addOne(Keys.AwsLogStreamNames, Seq(container.logOptions.stream)) + + accountIdOpt.foreach { accountId => + builder.addOne(Keys.AwsLogGroupArns, Seq(container.logOptions.logGroupArn(accountId))) + builder.addOne(Keys.AwsLogStreamArns, Seq(container.logOptions.logStreamArn(accountId))) + } + + builder.addOne(Keys.AwsEcsContainerArn, container.containerArn) + builder.addOne(Keys.AwsEcsContainerImageId, container.imageId) + builder.addOne(Keys.AwsEcsTaskArn, task.taskArn) + builder.addOne(Keys.AwsEcsLaunchType, task.launchType) + builder.addOne(Keys.AwsEcsTaskFamily, task.family) + builder.addOne(Keys.AwsEcsTaskRevision, task.revision) + + if (task.cluster.contains(":")) { + builder.addOne(Keys.AwsEcsClusterArn, task.cluster) + } else { + regionOpt.zip(accountIdOpt).foreach { case (region, account) => + builder.addOne(Keys.AwsEcsClusterArn, s"arn:aws:ecs:$region:$account:cluster/${task.cluster}") + } + } + + TelemetryResource(builder.result(), Some(SchemaUrls.Current)) + } + + private def mkClient: Resource[F, Client[F]] = + customClient match { + case Some(client) => Resource.pure(client) + case None => EmberClientBuilder.default[F].withTimeout(Const.Timeout).build + } + + // the format is: arn:aws:ecs:eu-west-1:12345678901:task/production/abc123 + private def parseAccountAndRegion(taskArn: String): (Option[String], Option[String]) = + taskArn.split(":").toList match { + case _ :: _ :: _ :: region :: account :: _ => + (Some(region), Some(account)) + + case _ => + (None, None) + } + + private def parseDockerImage(fqn: String): (Option[String], Option[String]) = { + fqn.split(":", 2).toList match { + case name :: tag :: Nil => + (Some(name), tag.split("@").headOption) + + case _ => + (None, None) + } + } +} + +object AWSECSDetector { + + private object Const { + val Name = "aws-ecs" + val CloudProvider = "aws" + val CloudPlatform = "aws_ecs" + val MetadataV3Key = "ECS_CONTAINER_METADATA_URI" + val MetadataV4Key = "ECS_CONTAINER_METADATA_URI_V4" + val Timeout: FiniteDuration = 2.seconds + } + + private object Keys { + val CloudProvider: AttributeKey[String] = AttributeKey("cloud.provider") + val CloudPlatform: AttributeKey[String] = AttributeKey("cloud.platform") + val CloudAccountId: AttributeKey[String] = AttributeKey("cloud.account.id") + val CloudAvailabilityZones: AttributeKey[String] = AttributeKey("cloud.availability_zone") + val CloudRegion: AttributeKey[String] = AttributeKey("cloud.region") + val CloudResourceId: AttributeKey[String] = AttributeKey("cloud.resource_id") + val ContainerId: AttributeKey[String] = AttributeKey("container.id") + val ContainerName: AttributeKey[String] = AttributeKey("container.name") + val ContainerImageName: AttributeKey[String] = AttributeKey("container.image.name") + val ContainerImageTags: AttributeKey[Seq[String]] = AttributeKey("container.image.tags") + val AwsEcsClusterArn: AttributeKey[String] = AttributeKey("aws.ecs.cluster.arn") + val AwsEcsContainerImageId: AttributeKey[String] = AttributeKey("aws.ecs.container.image.id") + val AwsEcsContainerArn: AttributeKey[String] = AttributeKey("aws.ecs.container.arn") + val AwsEcsLaunchType: AttributeKey[String] = AttributeKey("aws.ecs.launchtype") + val AwsEcsTaskArn: AttributeKey[String] = AttributeKey("aws.ecs.task.arn") + val AwsEcsTaskFamily: AttributeKey[String] = AttributeKey("aws.ecs.task.family") + val AwsEcsTaskRevision: AttributeKey[String] = AttributeKey("aws.ecs.task.revision") + val AwsLogGroupNames: AttributeKey[Seq[String]] = AttributeKey("aws.log.group.names") + val AwsLogGroupArns: AttributeKey[Seq[String]] = AttributeKey("aws.log.group.arns") + val AwsLogStreamNames: AttributeKey[Seq[String]] = AttributeKey("aws.log.stream.names") + val AwsLogStreamArns: AttributeKey[Seq[String]] = AttributeKey("aws.log.stream.arns") + } + + /** The detector fetches ECS container and task metadata. + * + * The base uri is obtained from `ECS_CONTAINER_METADATA_URI_V4` or `ECS_CONTAINER_METADATA_URI` env variable. + * + * @example + * {{{ + * OpenTelemetrySdk + * .autoConfigured[IO]( + * // register OTLP exporters configurer + * _.addExportersConfigurer(OtlpExportersAutoConfigure[IO]) + * // register AWS ECS detector + * .addResourceDetector(AWSECSDetector[IO]) + * ) + * .use { autoConfigured => + * val sdk = autoConfigured.sdk + * ??? + * } + * }}} + * + * @see + * [[https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-metadata-endpoint-v3.html]] + * + * @see + * [[https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-metadata-endpoint-v4.html]] + */ + def apply[F[_]: Async: Network: Env: Console]: TelemetryResourceDetector[F] = + new AWSECSDetector[F](None) + + /** The detector fetches ECS container and task metadata using the given `client`. + * + * The base uri is obtained from `ECS_CONTAINER_METADATA_URI_V4` or `ECS_CONTAINER_METADATA_URI` env variable. + * + * @example + * {{{ + * OpenTelemetrySdk + * .autoConfigured[IO]( + * // register OTLP exporters configurer + * _.addExportersConfigurer(OtlpExportersAutoConfigure[IO]) + * // register AWS ECS detector + * .addResourceDetector(AWSECSDetector[IO]) + * ) + * .use { autoConfigured => + * val sdk = autoConfigured.sdk + * ??? + * } + * }}} + * + * @see + * [[https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-metadata-endpoint-v3.html]] + * + * @see + * [[https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-metadata-endpoint-v4.html]] + */ + def apply[F[_]: Async: Network: Env: Console](client: Client[F]): TelemetryResourceDetector[F] = + new AWSECSDetector[F](Some(client)) + + private final case class ContainerMetadata( + dockerId: String, + dockerName: String, + image: String, + imageId: String, + containerArn: String, + logOptions: ContainerMetadata.LogOptions + ) + + private object ContainerMetadata { + final case class LogOptions( + group: String, + region: String, + stream: String + ) { + def logGroupArn(accountId: String): String = + s"arn:aws:logs:$region:$accountId:log-group:$group" + + def logStreamArn(accountId: String): String = + s"${logGroupArn(accountId)}:log-stream:$stream" + } + + implicit val logOptionsDecoder: Decoder[LogOptions] = + Decoder.forProduct3( + "awslogs-group", + "awslogs-region", + "awslogs-stream" + )(LogOptions.apply) + + implicit val containerMetadataDecoder: Decoder[ContainerMetadata] = + Decoder.forProduct6( + "DockerId", + "DockerName", + "Image", + "ImageID", + "ContainerARN", + "LogOptions" + )(ContainerMetadata.apply) + } + + private final case class TaskMetadata( + availabilityZone: String, + cluster: String, + taskArn: String, + launchType: String, + family: String, + revision: String, + ) + + private object TaskMetadata { + implicit val taskMetadataDecoder: Decoder[TaskMetadata] = + Decoder.forProduct6( + "AvailabilityZone", + "Cluster", + "TaskARN", + "LaunchType", + "Family", + "Revision" + )(TaskMetadata.apply) + } +} diff --git a/sdk-contrib/aws/resource/src/test/scala/org/typelevel/otel4s/sdk/contrib/aws/resource/AWSECSDetectorSuite.scala b/sdk-contrib/aws/resource/src/test/scala/org/typelevel/otel4s/sdk/contrib/aws/resource/AWSECSDetectorSuite.scala new file mode 100644 index 000000000..c2ca4fd08 --- /dev/null +++ b/sdk-contrib/aws/resource/src/test/scala/org/typelevel/otel4s/sdk/contrib/aws/resource/AWSECSDetectorSuite.scala @@ -0,0 +1,179 @@ +/* + * 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.resource + +import cats.effect.IO +import cats.effect.std.Env +import io.circe.Json +import io.circe.syntax._ +import munit.CatsEffectSuite +import org.http4s.HttpApp +import org.http4s.HttpRoutes +import org.http4s.client.Client +import org.http4s.dsl.io._ +import org.typelevel.otel4s.Attribute +import org.typelevel.otel4s.Attributes +import org.typelevel.otel4s.sdk.TelemetryResource +import org.typelevel.otel4s.semconv.SchemaUrls +import org.typelevel.otel4s.semconv.experimental.attributes.AwsExperimentalAttributes._ +import org.typelevel.otel4s.semconv.experimental.attributes.CloudExperimentalAttributes._ +import org.typelevel.otel4s.semconv.experimental.attributes.ContainerExperimentalAttributes._ + +import scala.collection.immutable + +class AWSECSDetectorSuite extends CatsEffectSuite { + + private val containerMetadataUrl = "http://169.254.170.2/v4/5fb8fcdd-29f2-490f-8229-c1269d11a9d9" + + test("parse metadata response and add attributes") { + implicit val env: Env[IO] = constEnv("ECS_CONTAINER_METADATA_URI_V4" -> containerMetadataUrl) + + val accountId = "1234567890" + val availabilityZone = "eu-west-1a" + val region = "eu-west-1" + val taskId = "5e1b48d43b264ff9b0765d6b99886980" + val launchType = "EC2" + val family = "service-production" + val cluster = "production" + val revision = "11" + val logGroup = s"/ecs/$cluster/service" + val logGroupArn = s"arn:aws:logs:$region:$accountId:log-group:$logGroup" + val logStream = s"ecs/server/$taskId" + val logStreamArn = s"$logGroupArn:log-stream:$logStream" + val taskArn = s"arn:aws:ecs:$region:$accountId:task/$cluster/$taskId" + val dockerImageRepository = s"$accountId.dkr.ecr.$region.amazonaws.com/internal/repository" + val dockerImageTag = "8abab2a5" + val dockerImageSha = "sha256:7382b7779e6038c591e0b483768a637e0b9831d46faf10749e95311f2d7d522d" + val containerId = "83b2af5973dcd7d5e3769bc36402edc9ac26abbfdc3f185c68da9e30377ee1e1" + val containerName = s"ecs-$family-$revision-server-e4e7efbceda7b7c68601" + val containerArn = s"$taskArn/1a1c23fe-1718-4eed-9833-c3dc2dad712c" + val clusterArn = s"arn:aws:ecs:$region:$accountId:cluster/$cluster" + + val container = Json.obj( + "DockerId" := containerId, + "Name" := "server", + "DockerName" := containerName, + "Image" := s"$dockerImageRepository:$dockerImageTag", + "ImageID" := dockerImageSha, + "Ports" := Json.arr(), + "Labels" := Json.obj(), + "DesiredStatus" := "RUNNING", + "KnownStatus" := "RUNNING", + "Limits" := Json.obj(), + "CreatedAt" := "2024-09-12T18:08:55.593944224Z", + "StartedAt" := "2024-09-12T18:08:56.524454503Z", + "Type" := "NORMAL", + "Health" := Json.obj("status" := "HEALTHY"), + "Volumes" := Json.arr(), + "LogDriver" := "awslogs", + "LogOptions" := Json.obj( + "awslogs-group" := logGroup, + "awslogs-region" := region, + "awslogs-stream" := logStream + ), + "ContainerARN" := containerArn, + "Networks" := Json.arr() + ) + + val task = Json.obj( + "Cluster" := cluster, + "TaskARN" := taskArn, + "Family" := family, + "Revision" := revision, + "DesiredStatus" := "RUNNING", + "KnownStatus" := "RUNNING", + "PullStartedAt" := "2024-09-12T18:08:55.307387715Z", + "PullStoppedAt" := "2024-09-12T18:08:55.564707417Z", + "AvailabilityZone" := availabilityZone, + "LaunchType" := launchType, + "Containers" := Json.arr(container), + "VPCID" := "vpc-123", + "ServiceName" := "service" + ) + + val client = Client.fromHttpApp(mockServer(container, task)) + + val expected = TelemetryResource( + Attributes( + CloudProvider(CloudProviderValue.Aws.value), + CloudPlatform(CloudPlatformValue.AwsEcs.value), + CloudAccountId(accountId), + CloudRegion(region), + CloudAvailabilityZone(availabilityZone), + CloudResourceId(containerArn), + ContainerId(containerId), + ContainerName(containerName), + ContainerImageName(dockerImageRepository), + ContainerImageTags(Seq(dockerImageTag)), + Attribute("aws.ecs.container.image.id", dockerImageSha), + AwsEcsClusterArn(clusterArn), + AwsEcsContainerArn(containerArn), + AwsEcsLaunchtype(launchType), + AwsEcsTaskArn(taskArn), + AwsEcsTaskFamily(family), + AwsEcsTaskRevision(revision), + AwsLogGroupNames(Seq(logGroup)), + AwsLogStreamNames(Seq(logStream)), + AwsLogGroupArns(Seq(logGroupArn)), + AwsLogStreamArns(Seq(logStreamArn)), + ), + Some(SchemaUrls.Current) + ) + + AWSECSDetector[IO](client).detect.assertEquals(Some(expected)) + } + + test("return None when metadata response is unparsable") { + implicit val env: Env[IO] = constEnv("ECS_CONTAINER_METADATA_URI_V4" -> containerMetadataUrl) + val client = Client.fromHttpApp(mockServer(Json.obj(), Json.obj())) + AWSECSDetector[IO](client).detect.assertEquals(None) + } + + test("return None when the endpoint is unavailable exist") { + implicit val env: Env[IO] = constEnv("ECS_CONTAINER_METADATA_URI_V4" -> containerMetadataUrl) + val client = Client.fromHttpApp(HttpApp.notFound[IO]) + AWSECSDetector[IO](client).detect.assertEquals(None) + } + + test("return None when both ECS_CONTAINER_METADATA_URI and ECS_CONTAINER_METADATA_URI_V4 are undefined") { + implicit val env: Env[IO] = constEnv() + AWSECSDetector[IO].detect.assertEquals(None) + } + + private def constEnv(pairs: (String, String)*): Env[IO] = + new Env[IO] { + private val params = pairs.toMap + + def get(name: String): IO[Option[String]] = + IO.pure(params.get(name)) + + def entries: IO[immutable.Iterable[(String, String)]] = + IO.pure(params) + } + + private def mockServer(container: Json, task: Json): HttpApp[IO] = { + import org.http4s.circe.jsonEncoder + + HttpRoutes + .of[IO] { + case GET -> Root / "v4" / "5fb8fcdd-29f2-490f-8229-c1269d11a9d9" => Ok(container) + case GET -> Root / "v4" / "5fb8fcdd-29f2-490f-8229-c1269d11a9d9" / "task" => Ok(task) + } + .orNotFound + } + +}