packages feed

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

{-# LANGUAGE OverloadedStrings #-}

{- |
Module      :  OpenTelemetry.Resource.Detector.GCP
Copyright   :  (c) Ian Duncan, 2024
License     :  BSD-3
Description :  Detect GCP Compute Engine resource attributes via the metadata server
Maintainer  :  Ian Duncan
Stability   :  experimental

Queries the GCP metadata server (@metadata.google.internal@) to populate
resource attributes for services running on GCP Compute Engine, GKE,
Cloud Run, etc.

All requests include the required @Metadata-Flavor: Google@ header.
Returns an empty 'Resource' if the metadata server is not reachable.

Populates: @cloud.provider@, @cloud.platform@, @cloud.region@,
@cloud.availability_zone@, @cloud.account_id@, @host.id@, @host.name@,
@host.type@.

@since 0.1.0.2
-}
module OpenTelemetry.Resource.Detector.GCP (
  detectGCPCompute,
  detectGCPComputeSelf,
) where

import Data.Text (Text)
import qualified Data.Text as T
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)


metadataBase :: String
metadataBase = "http://metadata.google.internal/computeMetadata/v1/"


gcpHeaders :: [(Text, Text)]
gcpHeaders = [("Metadata-Flavor", "Google")]


fetchGCP :: MetadataClient -> String -> IO (Maybe Text)
fetchGCP client path =
  fetchTextWithHeaders client (metadataBase ++ path) gcpHeaders


{- | Self-contained GCP detector suitable for the resource detector registry.
Checks for GCP env vars first, creates its own 'MetadataClient', then
queries the metadata server. Returns an empty resource if not on GCP or
the metadata server is unreachable.
-}
detectGCPComputeSelf :: IO Resource
detectGCPComputeSelf = do
  mProject <- lookupEnv "GOOGLE_CLOUD_PROJECT"
  mGcloud <- lookupEnv "GCLOUD_PROJECT"
  mGcp <- lookupEnv "GCP_PROJECT"
  mKService <- lookupEnv "K_SERVICE"
  let isGcp = any (/= Nothing) [mProject, mGcloud, mGcp, mKService]
  if isGcp
    then do
      client <- newMetadataClient
      detectGCPCompute client
    else pure $ mkResource []


{- | Detect GCP Compute Engine attributes via the metadata server.
Returns an empty resource if not running on GCP.

Also detects GKE by checking for @instance\/attributes\/cluster-name@
on the metadata server. If present, sets @cloud.platform@ to
@gcp_kubernetes_engine@ and populates @k8s.cluster.name@.
-}
detectGCPCompute :: MetadataClient -> IO Resource
detectGCPCompute client = do
  mProjectId <- fetchGCP client "project/project-id"
  case mProjectId of
    Nothing -> pure $ mkResource []
    Just projectId -> do
      mInstanceId <- fetchGCP client "instance/id"
      mInstanceName <- fetchGCP client "instance/name"
      mZone <- fetchGCP client "instance/zone"
      mMachineType <- fetchGCP client "instance/machine-type"
      mClusterName <- fetchGCP client "instance/attributes/cluster-name"

      let mAz = extractLastSegment <$> mZone
          mRegion = extractRegionFromZone =<< mAz
          mHostType = extractLastSegment <$> mMachineType
          platform :: Text
          platform = case mClusterName of
            Just _ -> "gcp_kubernetes_engine"
            Nothing -> "gcp_compute_engine"

      pure $
        mkResource
          [ unkey SC.cloud_provider .= ("gcp" :: Text)
          , unkey SC.cloud_platform .= platform
          , unkey SC.cloud_region .=? mRegion
          , unkey SC.cloud_availabilityZone .=? mAz
          , unkey SC.cloud_account_id .= projectId
          , unkey SC.host_id .=? mInstanceId
          , unkey SC.host_name .=? mInstanceName
          , unkey SC.host_type .=? mHostType
          , unkey SC.k8s_cluster_name .=? mClusterName
          ]


-- Metadata server returns zone as "projects/{num}/zones/{zone}"
-- and machine-type as "projects/{num}/machineTypes/{type}".
extractLastSegment :: Text -> Text
extractLastSegment t = case T.splitOn "/" t of
  [] -> t
  parts -> last parts


-- "us-central1-a" -> "us-central1"
extractRegionFromZone :: Text -> Maybe Text
extractRegionFromZone az =
  let parts = T.splitOn "-" az
  in if length parts >= 3
       then Just $ T.intercalate "-" (take (length parts - 1) parts)
       else Nothing