Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

sdk-contrib: add AWSBeanstalkDetector #774

Merged
merged 1 commit into from
Sep 14, 2024
Merged
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
1 change: 1 addition & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,7 @@ lazy val `sdk-contrib-aws-resource` =
libraryDependencies ++= Seq(
"org.http4s" %%% "http4s-ember-client" % Http4sVersion,
"org.http4s" %%% "http4s-circe" % Http4sVersion,
"io.circe" %%% "circe-parser" % CirceVersion,
"org.http4s" %%% "http4s-dsl" % Http4sVersion % Test
)
)
Expand Down
59 changes: 54 additions & 5 deletions docs/sdk/aws-resource-detectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -106,6 +106,51 @@ AWSEC2Detector[IO](uri"", client).detect.unsafeRunSync().foreach { resource =>
println("```")
```

### 4. aws-beanstalk

The detector parses environment details from the `/var/elasticbeanstalk/xray/environment.conf` file to configure the telemetry resource.

Expected configuration attributes:
- `deployment_id`
- `version_label`
- `environment_name`

```scala mdoc:reset:passthrough
import cats.effect.IO
import cats.effect.unsafe.implicits.global
import fs2.io.file.Files
import io.circe.Json
import io.circe.syntax._
import org.typelevel.otel4s.sdk.contrib.aws.resource._

val content = Json.obj(
"deployment_id" := 2,
"version_label" := "1.1",
"environment_name" := "production-eu-west"
).noSpaces

println("The content of the `/var/elasticbeanstalk/xray/environment.conf` file: ")
println("```json")
println(content)
println("```")

println("Detected resource: ")
println("```yaml")
val detected = Files[IO].tempFile.use { path =>
for {
_ <- fs2.Stream(content).through(fs2.text.utf8.encode).through(Files[IO].writeAll(path)).compile.drain
r <- AWSBeanstalkDetector[IO](path).detect
} yield r
}.unsafeRunSync()

detected.foreach { resource =>
resource.attributes.toList.sortBy(_.key.name).foreach { attribute =>
println(attribute.key.name + ": " + attribute.value)
}
}
println("```")
```

## Getting Started

@:select(build-tool)
Expand Down Expand Up @@ -167,6 +212,8 @@ object TelemetryApp extends IOApp.Simple {
.addResourceDetector(AWSLambdaDetector[IO])
// register AWS EC2 detector
.addResourceDetector(AWSEC2Detector[IO])
// register AWS Beanstalk detector
.addResourceDetector(AWSBeanstalkDetector[IO])
)
.use { autoConfigured =>
val sdk = autoConfigured.sdk
Expand Down Expand Up @@ -203,6 +250,8 @@ object TelemetryApp extends IOApp.Simple {
.addResourceDetector(AWSLambdaDetector[IO])
// register AWS EC2 detector
.addResourceDetector(AWSEC2Detector[IO])
// register AWS Beanstalk detector
.addResourceDetector(AWSBeanstalkDetector[IO])
)
.use { autoConfigured =>
program(autoConfigured.tracerProvider)
Expand Down Expand Up @@ -231,21 +280,21 @@ 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-beanstalk"
envVars ++= Map("OTEL_OTEL4S_RESOURCE_DETECTORS_ENABLE" -> "aws-lambda,aws-ec2,aws-beanstalk")
```

@:choice(scala-cli)

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-beanstalk
```

@:choice(shell)

```shell
$ export OTEL_OTEL4S_RESOURCE_DETECTORS_ENABLED=aws-lambda,aws-ec2
$ export OTEL_OTEL4S_RESOURCE_DETECTORS_ENABLED=aws-lambda,aws-ec2,aws-beanstalk
```
@:@
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/*
* 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.Concurrent
import cats.effect.std.Console
import cats.syntax.flatMap._
import cats.syntax.functor._
import cats.syntax.show._
import fs2.io.file.Files
import fs2.io.file.Path
import fs2.text
import io.circe.Decoder
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 org.typelevel.otel4s.semconv.attributes.ServiceAttributes

private class AWSBeanstalkDetector[F[_]: Concurrent: Files: Console] private (
path: Path
) extends TelemetryResourceDetector[F] {

import AWSBeanstalkDetector.Const
import AWSBeanstalkDetector.Keys
import AWSBeanstalkDetector.Metadata

def name: String = Const.Name

def detect: F[Option[TelemetryResource]] =
Files[F]
.exists(path)
.ifM(
parseFile,
Console[F].errorln(s"AWSBeanstalkDetector: config file doesn't exist at path $path").as(None)
)

private def parseFile: F[Option[TelemetryResource]] =
for {
content <- Files[F].readAll(path).through(text.utf8.decode).compile.foldMonoid
resource <- io.circe.parser
.decode[Metadata](content)
.fold(
e => Console[F].errorln(show"AWSBeanstalkDetector: cannot parse metadata from $path. $e").as(None),
m => Concurrent[F].pure(Some(build(m)))
)
} yield resource

private def build(metadata: Metadata): TelemetryResource = {
val builder = Attributes.newBuilder

builder.addOne(Keys.CloudProvider, Const.CloudProvider)
builder.addOne(Keys.CloudPlatform, Const.CloudPlatform)

metadata.deploymentId.foreach(id => builder.addOne(Keys.ServiceInstanceId, id.toString))
metadata.versionLabel.foreach(v => builder.addOne(Keys.ServiceVersion, v))
metadata.environmentName.foreach(n => builder.addOne(Keys.ServiceNamespace, n))

TelemetryResource(builder.result(), Some(SchemaUrls.Current))
}

}

object AWSBeanstalkDetector {

private object Const {
val Name = "aws-beanstalk"
val CloudProvider = "aws"
val CloudPlatform = "aws_elastic_beanstalk"
val ConfigFilePath = "/var/elasticbeanstalk/xray/environment.conf"
}

private object Keys {
val CloudProvider: AttributeKey[String] = AttributeKey("cloud.provider")
val CloudPlatform: AttributeKey[String] = AttributeKey("cloud.platform")
val ServiceInstanceId: AttributeKey[String] = AttributeKey("service.instance.id")
val ServiceNamespace: AttributeKey[String] = AttributeKey("service.namespace")
val ServiceVersion: AttributeKey[String] = ServiceAttributes.ServiceVersion
}

private final case class Metadata(
deploymentId: Option[Int],
versionLabel: Option[String],
environmentName: Option[String]
)

private object Metadata {
implicit val metadataDecoder: Decoder[Metadata] =
Decoder.forProduct3(
"deployment_id",
"version_label",
"environment_name"
)(Metadata.apply)
}

/** The detector parses environment details from the `/var/elasticbeanstalk/xray/environment.conf` file.
*
* Expected configuration attributes:
* - `deployment_id`
* - `version_label`
* - `environment_name`
*
* @example
* {{{
* OpenTelemetrySdk
* .autoConfigured[IO](
* // register OTLP exporters configurer
* _.addExportersConfigurer(OtlpExportersAutoConfigure[IO])
* // register AWS Beanstalk detector
* .addResourceDetector(AWSBeanstalkDetector[IO])
* )
* .use { autoConfigured =>
* val sdk = autoConfigured.sdk
* ???
* }
* }}}
*/
def apply[F[_]: Concurrent: Files: Console]: TelemetryResourceDetector[F] =
new AWSBeanstalkDetector[F](Path(Const.ConfigFilePath))

/** The detector parses environment details from the file at the given `path`.
*
* Expected configuration attributes:
* - `deployment_id`
* - `version_label`
* - `environment_name`
*
* @example
* {{{
* OpenTelemetrySdk
* .autoConfigured[IO](
* // register OTLP exporters configurer
* _.addExportersConfigurer(OtlpExportersAutoConfigure[IO])
* // register AWS Beanstalk detector
* .addResourceDetector(AWSBeanstalkDetector[IO])
* )
* .use { autoConfigured =>
* val sdk = autoConfigured.sdk
* ???
* }
* }}}
*/
def apply[F[_]: Concurrent: Files: Console](path: Path): TelemetryResourceDetector[F] =
new AWSBeanstalkDetector[F](path)

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* 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 fs2.Stream
import fs2.io.file.Files
import fs2.io.file.Path
import io.circe.Json
import io.circe.syntax._
import munit.CatsEffectSuite
import org.typelevel.otel4s.Attributes
import org.typelevel.otel4s.sdk.TelemetryResource
import org.typelevel.otel4s.semconv.SchemaUrls
import org.typelevel.otel4s.semconv.attributes.ServiceAttributes
import org.typelevel.otel4s.semconv.experimental.attributes.CloudExperimentalAttributes._
import org.typelevel.otel4s.semconv.experimental.attributes.ServiceExperimentalAttributes._

class AWSBeanstalkDetectorSuite extends CatsEffectSuite {

test("parse config file and add attributes") {
Files[IO].tempFile.use { path =>
val id = 11
val versionLabel = "1"
val envName = "production-env"

val content = Json.obj(
"deployment_id" := id,
"version_label" := versionLabel,
"environment_name" := envName
)

val expected = TelemetryResource(
Attributes(
CloudProvider(CloudProviderValue.Aws.value),
CloudPlatform(CloudPlatformValue.AwsElasticBeanstalk.value),
ServiceInstanceId(id.toString),
ServiceNamespace(envName),
ServiceAttributes.ServiceVersion(versionLabel)
),
Some(SchemaUrls.Current)
)

for {
_ <- write(content.noSpaces, path)
r <- AWSBeanstalkDetector[IO](path).detect
} yield assertEquals(r, Some(expected))
}
}

test("add only provider and platform when file is empty") {
Files[IO].tempFile.use { path =>
val expected = TelemetryResource(
Attributes(
CloudProvider(CloudProviderValue.Aws.value),
CloudPlatform(CloudPlatformValue.AwsElasticBeanstalk.value)
),
Some(SchemaUrls.Current)
)

for {
_ <- write("{}", path)
r <- AWSBeanstalkDetector[IO](path).detect
} yield assertEquals(r, Some(expected))
}
}

test("return None when config file is unparsable") {
Files[IO].tempFile.use { path =>
AWSBeanstalkDetector[IO](path).detect.assertEquals(None)
}
}

test("return None when the config file doesn't exist") {
AWSBeanstalkDetector[IO].detect.assertEquals(None)
}

private def write(content: String, path: Path): IO[Unit] =
Stream(content).through(fs2.text.utf8.encode).through(Files[IO].writeAll(path)).compile.drain

}