packages feed

hs-opentelemetry-sdk-1.0.0.0: src/OpenTelemetry/Resource/Detector/AWS/ECS.hs

{-# LANGUAGE OverloadedStrings #-}

{- |
Module      :  OpenTelemetry.Resource.Detector.AWS.ECS
Copyright   :  (c) Ian Duncan, 2024
License     :  BSD-3
Description :  Detect ECS task and container resource attributes
Maintainer  :  Ian Duncan
Stability   :  experimental

Queries the ECS container metadata endpoint (v4) to populate resource
attributes for services running on Amazon ECS (both EC2 and Fargate
launch types).

Returns an empty 'Resource' if @ECS_CONTAINER_METADATA_URI_V4@ is not set.

Populates: @cloud.provider@, @cloud.platform@, @cloud.region@,
@cloud.account_id@, @cloud.availability_zone@, @aws.ecs.task.arn@,
@aws.ecs.task.family@, @aws.ecs.task.revision@, @aws.ecs.cluster.arn@,
@aws.ecs.launchtype@, @container.id@, @container.name@,
@aws.ecs.container.arn@, @aws.log.group.names@, @aws.log.stream.names@.

@since 0.1.0.2
-}
module OpenTelemetry.Resource.Detector.AWS.ECS (
  detectECS,
  detectECSSelf,
) where

import Data.Aeson (FromJSON (..), withObject, (.:), (.:?))
import Data.Maybe (listToMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import OpenTelemetry.Attributes (Attribute)
import OpenTelemetry.Attributes.Key (unkey)
import OpenTelemetry.Resource (Resource, mkResource, (.=), (.=?))
import OpenTelemetry.Resource.Detector.Metadata
import qualified OpenTelemetry.SemanticConventions as SC
import System.Environment (lookupEnv)


{- | Self-contained ECS detector suitable for the resource detector registry.
Creates its own 'MetadataClient'. Returns an empty resource if
@ECS_CONTAINER_METADATA_URI_V4@ is not set.
-}
detectECSSelf :: IO Resource
detectECSSelf = do
  client <- newMetadataClient
  detectECS client


{- | Detect ECS task metadata from the container metadata endpoint.
Returns an empty resource if not running on ECS.
-}
detectECS :: MetadataClient -> IO Resource
detectECS client = do
  mUri <- lookupEnv "ECS_CONTAINER_METADATA_URI_V4"
  case mUri of
    Nothing -> pure $ mkResource []
    Just uri -> do
      mTask <- fetchJSON client (uri ++ "/task") :: IO (Maybe TaskMetadata)
      mContainer <- fetchJSON client uri :: IO (Maybe ContainerMetadata)
      case mTask of
        Nothing -> pure $ mkResource []
        Just task -> do
          let arnParts = T.splitOn ":" (taskArn task)
              mRegion = safeIndex arnParts 3
              mAcctId = safeIndex arnParts 4
              cluster = normalizeArn (taskCluster task) arnParts "cluster"
              mAz = taskAvailabilityZone task
              launchType = T.toLower <$> taskLaunchType task

              containerAttrs :: [Maybe (Text, Attribute)]
              containerAttrs = case mContainer of
                Nothing -> []
                Just c ->
                  [ unkey SC.container_name .= containerName c
                  , unkey SC.container_id .=? containerDockerId c
                  , unkey SC.aws_ecs_container_arn .=? containerArn c
                  ]
                    ++ logAttrs c

          pure $
            mkResource $
              [ unkey SC.cloud_provider .= ("aws" :: Text)
              , unkey SC.cloud_platform .= ("aws_ecs" :: Text)
              , unkey SC.cloud_region .=? mRegion
              , unkey SC.cloud_account_id .=? mAcctId
              , unkey SC.cloud_availabilityZone .=? mAz
              , unkey SC.aws_ecs_task_arn .= taskArn task
              , unkey SC.aws_ecs_task_family .= taskFamily task
              , unkey SC.aws_ecs_task_revision .= taskRevision task
              , unkey SC.aws_ecs_cluster_arn .= cluster
              , unkey SC.aws_ecs_launchtype .=? launchType
              ]
                ++ containerAttrs


logAttrs :: ContainerMetadata -> [Maybe (Text, Attribute)]
logAttrs c = case containerLogDriver c of
  Just "awslogs" -> case containerLogOptions c of
    Just opts ->
      [ unkey SC.aws_log_group_names .=? logGroup opts
      , unkey SC.aws_log_stream_names .=? logStream opts
      ]
    Nothing -> []
  _ -> []


-- JSON types for ECS metadata endpoint responses

data TaskMetadata = TaskMetadata
  { taskArn :: !Text
  , taskFamily :: !Text
  , taskRevision :: !Text
  , taskCluster :: !Text
  , taskAvailabilityZone :: !(Maybe Text)
  , taskLaunchType :: !(Maybe Text)
  }


instance FromJSON TaskMetadata where
  parseJSON = withObject "TaskMetadata" $ \o ->
    TaskMetadata
      <$> o .: "TaskARN"
      <*> o .: "Family"
      <*> o .: "Revision"
      <*> o .: "Cluster"
      <*> o .:? "AvailabilityZone"
      <*> o .:? "LaunchType"


data ContainerMetadata = ContainerMetadata
  { containerDockerId :: !(Maybe Text)
  , containerName :: !Text
  , containerArn :: !(Maybe Text)
  , containerLogDriver :: !(Maybe Text)
  , containerLogOptions :: !(Maybe LogOptions)
  }


instance FromJSON ContainerMetadata where
  parseJSON = withObject "ContainerMetadata" $ \o ->
    ContainerMetadata
      <$> o .:? "DockerId"
      <*> o .: "Name"
      <*> o .:? "ContainerARN"
      <*> o .:? "LogDriver"
      <*> o .:? "LogOptions"


data LogOptions = LogOptions
  { logGroup :: !(Maybe Text)
  , logStream :: !(Maybe Text)
  , _logRegion :: !(Maybe Text)
  }


instance FromJSON LogOptions where
  parseJSON = withObject "LogOptions" $ \o ->
    LogOptions
      <$> o .:? "awslogs-group"
      <*> o .:? "awslogs-stream"
      <*> o .:? "awslogs-region"


normalizeArn :: Text -> [Text] -> Text -> Text
normalizeArn val arnParts suffix
  | "arn:" `T.isPrefixOf` val = val
  | length arnParts >= 5 =
      let base = T.intercalate ":" (take 5 arnParts)
      in base <> ":" <> suffix <> "/" <> val
  | otherwise = val


safeIndex :: [a] -> Int -> Maybe a
safeIndex xs i = listToMaybe (drop i xs)