packages feed

eventlog-live-0.8.0.0: src-app/GHC/Eventlog/Live/Otlp/Environment.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module GHC.Eventlog.Live.Otlp.Environment (
  -- * OpenTelemetry Exporter Options
  lookupLogLevel,
  ServiceName (..),
  ResourceAttributes (..),
  lookupResourceAttributes,

  -- ** OpenTelemetry Exporter Options
  PerSignal (..),
  Signal (..),
  forSignal,
  ExporterOptions (..),
  lookupExporterOptions,
  Protocol (..),
  Endpoint (..),
  defaultEndpointFor,
  defaultPortFor,
  Compression (..),
  Timeout (..),
  OtlpExporterOptions (..),
) where

import Control.Monad (join, unless)
import Control.Monad.Trans.Class (MonadTrans (..))
import Control.Monad.Trans.Except (ExceptT, throwE)
import Data.CaseInsensitive qualified as CI
import Data.Default (Default (..))
import Data.Foldable (for_)
import Data.Hashable (Hashable)
import Data.List qualified as L
import Data.Maybe (catMaybes, fromMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Traversable (for)
import Data.Word (Word16)
import GHC.Eventlog.Live.Data.Severity (Severity (..), fromSeverityString)
import GHC.Eventlog.Live.Logger (Logger, writeLog)
import GHC.IsList qualified as IsList
import GHC.Records (HasField (..))
import Network.URI (URI (..), URIAuth (..))
import Network.URI qualified as URI
import OpenTelemetry.Baggage (Baggage, decodeBaggageHeader)
import OpenTelemetry.Baggage qualified as Baggage
import System.Environment (lookupEnv)
import Text.Printf (printf)
import Text.Read (readMaybe)

--------------------------------------------------------------------------------
-- OpenTelemetry Signals
--------------------------------------------------------------------------------

{- |
The signals supported by OTLP.
-}
data Signal
  = TRACES
  | METRICS
  | LOGS
  | PROFILES
  deriving (Show, Enum, Bounded)

{- |
A collection of values for per signal.
-}
data PerSignal a
  = Shared !a
  | PerSignal
      { forTRACES :: !a
      , forMETRICS :: !a
      , forLOGS :: !a
      , forPROFILES :: !a
      }
  deriving stock (Functor, Foldable, Traversable)

{- |
Get the element for a specific signal.
-}
forSignal :: PerSignal a -> Signal -> a
forSignal = \case
  Shared a -> const a
  PerSignal{..} -> \case
    TRACES -> forTRACES
    METRICS -> forMETRICS
    LOGS -> forLOGS
    PROFILES -> forPROFILES

--------------------------------------------------------------------------------
-- OpenTelemetry SDK Options
--------------------------------------------------------------------------------

{- |
Supported exporters.
-}
data ExporterType = Otlp
  deriving (Eq, Show)

{- |
Lookup the OpenTelemetry exporter type for a signal from the environment.

See: https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/#exporter-selection
-}
lookupExporterType ::
  Logger IO ->
  Signal ->
  ExceptT String IO (Maybe ExporterType)
lookupExporterType logger signal = do
  let otelExporter = "OTEL_" <> show signal <> "_EXPORTER"
  lift (lookupEnv otelExporter) >>= \case
    Nothing ->
      pure (Just Otlp)
    Just otelExporterType ->
      readExporterType logger otelExporter otelExporterType

{- |
Exporter options for each exporter type.
-}
newtype ExporterOptions = ExporterOptions'Otlp OtlpExporterOptions
  deriving (Eq, Show)

{- |
Lookup the OpenTelemetry OTLP Exporter options from the environment.

See: https://opentelemetry.io/docs/specs/otel/protocol/exporter
-}
lookupExporterOptions ::
  Logger IO ->
  ExceptT String IO (PerSignal (Maybe ExporterOptions))
lookupExporterOptions logger = do
  !tracesExporterType <- lookupExporterType logger TRACES
  !tracesExporterOptions <-
    for tracesExporterType $ \Otlp ->
      ExporterOptions'Otlp <$> lookupOtlpExporterOptions logger (Just TRACES)

  !metricsExporterType <- lookupExporterType logger METRICS
  !metricsExporterOptions <-
    for metricsExporterType $ \Otlp ->
      ExporterOptions'Otlp <$> lookupOtlpExporterOptions logger (Just METRICS)

  !logsExporterType <- lookupExporterType logger LOGS
  !logsExporterOptions <-
    for logsExporterType $ \Otlp ->
      ExporterOptions'Otlp <$> lookupOtlpExporterOptions logger (Just LOGS)

  !profilesExporterType <- lookupExporterType logger PROFILES
  !profilesExporterOptions <-
    for profilesExporterType $ \Otlp ->
      ExporterOptions'Otlp <$> lookupOtlpExporterOptions logger (Just PROFILES)

  let exporterOptions =
        [tracesExporterOptions, metricsExporterOptions, logsExporterOptions, profilesExporterOptions]
  pure $
    if allSame exporterOptions
      then
        Shared tracesExporterOptions
      else
        PerSignal
          { forTRACES = tracesExporterOptions
          , forMETRICS = metricsExporterOptions
          , forLOGS = logsExporterOptions
          , forPROFILES = profilesExporterOptions
          }

{- |
Lookup the OpenTelemetry Log Level from the environment.
-}
lookupLogLevel ::
  ExceptT String IO Severity
lookupLogLevel = do
  -- Lookup OTEL_LOG_LEVEL
  let otelLogLevel = "OTEL_LOG_LEVEL"
  fmap (fromMaybe INFO)
    <$> traverse (readSeverity otelLogLevel)
    =<< lift (lookupEnv otelLogLevel)

{- |
OpenTelemetry Service Name.
-}
newtype ServiceName = ServiceName {serviceName :: Text}
  deriving newtype (Eq, Hashable, Show)

{- |
OpenTelemetry Resource Attributes.
-}
newtype ResourceAttributes = ResourceAttributes
  { resourceAttributeBaggage :: Baggage
  }

{- |
Internal helper.

The @service.name@ token.
-}
serviceNameToken :: Baggage.Token
serviceNameToken = [Baggage.token|service.name|]

instance HasField "serviceName" ResourceAttributes (Maybe ServiceName) where
  getField :: ResourceAttributes -> Maybe ServiceName
  getField ResourceAttributes{..} =
    ServiceName <$> Baggage.getValue serviceNameToken resourceAttributeBaggage

instance HasField "attributes" ResourceAttributes [(Text, Text)] where
  getField :: ResourceAttributes -> [(Text, Text)]
  getField ResourceAttributes{..} =
    [ (key, value)
    | (token, Baggage.Element value _properties) <-
        IsList.toList (Baggage.values resourceAttributeBaggage)
    , let key = TE.decodeUtf8 (Baggage.tokenValue token)
    ]

{- |
Lookup the OpenTelemetry Resource Attributes from the environment.

This function reads both @OTEL_RESOURCE_ATTRIBUTES@ and @OTEL_SERVICE_NAME@.

See: https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/#general-sdk-configuration
-}
lookupResourceAttributes ::
  Logger IO ->
  ExceptT String IO (Maybe ResourceAttributes)
lookupResourceAttributes logger = do
  -- Lookup OTEL_SERVICE_NAME
  let otelServiceName = "OTEL_SERVICE_NAME"
  maybeServiceName <-
    fmap (singletonBaggage serviceNameToken . T.pack)
      <$> lift (lookupEnv otelServiceName)

  -- Lookup OTEL_RESOURCE_ATTRIBUTES
  let otelResourceAttributes = "OTEL_RESOURCE_ATTRIBUTES"
  maybeResourceAttributeBaggage <-
    traverse (readBaggage logger otelResourceAttributes)
      =<< lift (lookupEnv otelResourceAttributes)

  pure $ fmap ResourceAttributes (maybeServiceName <> maybeResourceAttributeBaggage)

{- |
Internal helper.

Convert a `Text` value to singleton `Baggage`.
-}
singletonBaggage :: Baggage.Token -> Text -> Baggage
singletonBaggage token value = Baggage.insert token (Baggage.element value) Baggage.empty

--------------------------------------------------------------------------------
-- OpenTelemetry Exporter Options
--------------------------------------------------------------------------------

{- |
OTLP protocol.
-}
data Protocol = Grpc | HttpProtobuf
  deriving (Eq, Show)

instance Default Protocol where
  def :: Protocol
  def = HttpProtobuf

{- |
OTLP endpoint.
-}
data Endpoint = Endpoint
  { host :: !String
  , port :: !(Maybe Word16)
  , path :: !String
  , secure :: !Bool
  }
  deriving (Eq)

toURI :: Endpoint -> URI
toURI endpoint =
  URI.rectify $
    URI.nullURI
      { uriScheme = if endpoint.secure then "https" else "http"
      , uriAuthority = Just URI.nullURIAuth{uriRegName = endpoint.host, uriPort = maybe "" show endpoint.port}
      , uriPath = endpoint.path
      }

instance Show Endpoint where
  showsPrec :: Int -> Endpoint -> ShowS
  showsPrec p = showsPrec p . toURI

{- |
The default endpoint for each protocol.

See: https://opentelemetry.io/docs/specs/otel/protocol/exporter/#configuration-options
-}
defaultEndpointFor :: Protocol -> Endpoint
defaultEndpointFor protocol =
  Endpoint{host = "localhost", port = Just $ defaultPortFor protocol, path = "", secure = False}

{- |
The default port for each protocol.
-}
defaultPortFor :: Protocol -> Word16
defaultPortFor = \case Grpc -> 4317; HttpProtobuf -> 4318

{- |
OTLP compression.
-}
data Compression = GZip
  deriving (Eq, Show)

{- |
OTLP timeout.

The value is specified in milliseconds.
The value @0@ should be interpreted as "no timeout".
-}
newtype Timeout = Timeout {timeoutMillis :: Word}
  deriving (Eq, Show)

instance Default Timeout where
  def :: Timeout
  def = Timeout 10_000

{- |
OpenTelemetry OTLP Exporter options.
-}
data OtlpExporterOptions = OtlpExporterOptions
  { protocol :: !Protocol
  , endpoint :: !Endpoint
  , maybeCertificate :: !(Maybe String)
  , maybeClientKey :: !(Maybe String)
  , maybeClientCertificate :: !(Maybe String)
  , maybeHeaders :: !(Maybe Baggage)
  , maybeCompression :: !(Maybe Compression)
  , timeout :: !Timeout
  }
  deriving (Eq, Show)

{- |
Lookup the OpenTelemetry OTLP Exporter options from the environment.

See: https://opentelemetry.io/docs/specs/otel/protocol/exporter
-}
lookupOtlpExporterOptions ::
  Logger IO ->
  Maybe Signal ->
  ExceptT String IO OtlpExporterOptions
lookupOtlpExporterOptions logger signal = do
  maybeProtocol <- lookupOtlpExporterOption logger signal PROTOCOL readProtocol
  let !protocol = fromMaybe HttpProtobuf maybeProtocol
  -- The INSECURE option should be used to infer http/https, but whenever
  -- http/https is specified in the endpoint, this should take precedence.
  maybeInsecure <- lookupOtlpExporterOption logger signal INSECURE readBoolean
  maybeEndpoint <- lookupOtlpExporterOption logger signal ENDPOINT (readEndpoint maybeInsecure)
  let !endpoint = fromMaybe (defaultEndpointFor protocol) maybeEndpoint
  maybeCertificate <- lookupOtlpExporterOption logger signal CERTIFICATE readString
  maybeClientKey <- lookupOtlpExporterOption logger signal CLIENT_KEY readString
  maybeClientCertificate <- lookupOtlpExporterOption logger signal CLIENT_CERTIFICATE readString
  maybeHeaders <- lookupOtlpExporterOption logger signal HEADERS readBaggage
  maybeCompression <- join <$> lookupOtlpExporterOption logger signal COMPRESSION readCompression
  maybeTimeout <- lookupOtlpExporterOption logger signal TIMEOUT readTimeout
  let timeout = fromMaybe def maybeTimeout
  pure OtlpExporterOptions{..}

{- |
Internal helper.

The exporter options supported by OTLP.
-}
data OtlpExporterOption
  = PROTOCOL
  | ENDPOINT
  | INSECURE
  | CERTIFICATE
  | CLIENT_KEY
  | CLIENT_CERTIFICATE
  | HEADERS
  | COMPRESSION
  | TIMEOUT
  deriving (Show)

{- |
Internal helper.

Render an exporter option name as a string.

>>> exporterOptionName Nothing        ENDPOINT == "OTEL_EXPORTER_OTLP_ENDPOINT"
>>> exporterOptionName (Just TRACES)  ENDPOINT == "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"
>>> exporterOptionName (Just METRICS) ENDPOINT == "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT"
>>> exporterOptionName (Just LOGS)    ENDPOINT == "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT"
-}
exporterOptionName :: Maybe Signal -> OtlpExporterOption -> String
exporterOptionName signal option =
  L.intercalate "_" . catMaybes $
    [Just "OTEL_EXPORTER_OTLP", show <$> signal, Just (show option)]

{- |
Internal helper.

Look up an exporter option, cascading from signal-specific for generic options.
-}
lookupOtlpExporterOption ::
  Logger IO ->
  Maybe Signal ->
  OtlpExporterOption ->
  (Logger IO -> String -> String -> ExceptT e IO a) ->
  ExceptT e IO (Maybe a)
lookupOtlpExporterOption logger maybeSignal option parser =
  -- Look up the exporter option for the specified signal, if any.
  case maybeSignal of
    Nothing ->
      lookupOtlpExporterOptionOnlyFor Nothing
    Just signal ->
      lookupOtlpExporterOptionOnlyFor (Just signal)
        >>= maybe (lookupOtlpExporterOptionOnlyFor Nothing) (pure . Just)
 where
  -- Look up the exporter option /only/ for the specified signal.
  lookupOtlpExporterOptionOnlyFor maybeSignal' =
    let optionName = exporterOptionName maybeSignal' option
     in lift (lookupEnv optionName) >>= traverse (parser logger optionName)

{- |
Internal helper.

Parse a protocol.
-}
readProtocol :: (Monad m) => Logger m -> String -> String -> ExceptT String m Protocol
readProtocol _logger optionName protocol
  | CI.mk protocol == "grpc" = pure Grpc
  | CI.mk protocol == "http/protobuf" = pure HttpProtobuf
  | CI.mk protocol == "http/json" =
      throwE $
        "Environment variable " <> optionName <> " specifies unsupported protocol 'http/json'."
  | otherwise =
      throwE $
        "Environment variable " <> optionName <> " specifies unknown protocol '" <> protocol <> "'."

{- |
Internal helper.

Parse a boolean.

See: https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/#boolean
-}
readBoolean :: (Monad m) => Logger m -> String -> String -> ExceptT String m Bool
readBoolean logger optionName boolean
  | CI.mk boolean == "true" = pure True
  | CI.mk boolean == "false" = pure False
  | otherwise = do
      lift . writeLog logger WARN . T.pack $
        "Environment variable " <> optionName <> " has non-boolean value '" <> boolean <> "'. Use 'true' or 'false'."
      pure False

{- |
Internal helper.

Parse a string.
-}
readString :: (Monad m) => Logger m -> String -> String -> ExceptT String m String
readString _logger _optionName = pure

{- |
Internal helper.

Show an URI.
-}
showURI :: URI -> String
showURI URI{..} =
  printf
    "URI {uriScheme = \"%s\", uriAuthority = %s, uriPath = \"%s\", uriQuery = \"%s\", uriFragment = \"%s\"}"
    uriScheme
    (show uriAuthority)
    uriPath
    uriQuery
    uriFragment

{- |
Internal helper.

Parse an endpoint.

@`readEndpoint` maybeInsecure@ uses the value of @maybeInsecure@, to determine
whether or not to infer the URI scheme as http or https, if unspecified.
-}
readEndpoint :: (Monad m) => Maybe Bool -> Logger m -> String -> String -> ExceptT String m Endpoint
readEndpoint maybeInsecure logger optionName = go True
 where
  go retry endpoint = do
    let maybeURI = URI.parseAbsoluteURI endpoint
    for_ maybeURI $ \uri ->
      lift . writeLog logger TRACE . T.pack $
        "Environment variable " <> optionName <> " specifies URI: " <> showURI uri
    case maybeURI of
      Nothing
        | retry -> do
            if maybeInsecure == Just False
              then go False ("https://" <> endpoint)
              else go False ("http://" <> endpoint)
        | otherwise ->
            throwE $ "Environment variable " <> optionName <> " specifies malformed URI '" <> endpoint <> "'."
      Just URI{uriAuthority = Nothing} -> do
        throwE $ "Environment variable " <> optionName <> " specifies URI without autority '" <> endpoint <> "'."
      Just URI{uriScheme}
        | uriScheme `notElem` ["http:", "https:"] ->
            throwE $ "Environment variable " <> optionName <> " specifies URI with unsupported scheme '" <> endpoint <> "'. Use 'http' or 'https'."
      Just URI{uriAuthority = Just URIAuth{..}, ..} -> do
        unless (null uriUserInfo) $
          lift . writeLog logger WARN . T.pack $
            "Environment variable " <> optionName <> " specifies URI with user info '" <> uriUserInfo <> "'."
        unless (null uriQuery) $
          lift . writeLog logger WARN . T.pack $
            "Environment variable " <> optionName <> " specifies URI with query '" <> uriQuery <> "'."
        unless (null uriFragment) $
          lift . writeLog logger WARN . T.pack $
            "Environment variable " <> optionName <> " specifies URI with fragment '" <> uriFragment <> "'."
        pure
          Endpoint
            { host = uriRegName
            , port = readMaybe @Word16 (dropColon uriPort)
            , path = uriPath
            , secure = uriScheme == "https:"
            }

  dropColon :: String -> String
  dropColon = \case (':' : str) -> str; str -> str

{- |
Internal helper.

Parse headers in the baggage format.
-}
readBaggage :: (Monad m) => Logger m -> String -> String -> ExceptT String m Baggage
readBaggage _logger optionName baggage =
  either onErr pure . decodeBaggageHeader . TE.encodeUtf8 . T.pack $ baggage
 where
  onErr e =
    throwE $ printf "Environment variable %s specifies malformed baggage '%s': %s" optionName baggage e

{- |
Internal helper.

Parse compression.
-}
readCompression :: (Monad m) => Logger m -> String -> String -> ExceptT String m (Maybe Compression)
readCompression _logger optionName compression
  | CI.mk compression == "none" = pure Nothing
  | CI.mk compression == "gzip" = pure (Just GZip)
  | otherwise =
      throwE $
        "Environment variable " <> optionName <> " specifies unknown compression '" <> compression <> "'. Use 'none' or 'gzip'."

{- |
Internal helper.

Parse a timeout.
-}
readTimeout :: (Monad m) => Logger m -> String -> String -> ExceptT String m Timeout
readTimeout logger optionName timeout
  | Just timeoutMillis <- readMaybe @Word timeout =
      pure (Timeout timeoutMillis)
  | otherwise = do
      lift . writeLog logger WARN . T.pack $
        "Environment variable " <> optionName <> " specifies malformed timeout '" <> timeout <> "'."
      pure def

{- |
Internal helper.

Parse a log level.

See: https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitytext
-}
readSeverity :: (Monad m) => String -> String -> ExceptT String m Severity
readSeverity optionName logLevel
  | Just severity <- fromSeverityString logLevel = pure severity
  | otherwise =
      throwE $
        "Environment variable " <> optionName <> " specifies malformed log level '" <> logLevel <> "'."

{- |
Internal helper.

Parse an exporter type.
-}
readExporterType :: (Monad m) => Logger m -> String -> String -> ExceptT String m (Maybe ExporterType)
readExporterType _logger optionName exporterType
  | CI.mk exporterType == "none" = pure Nothing
  | CI.mk exporterType == "otlp" = pure (Just Otlp)
  | CI.mk exporterType `elem` ["zipkin", "prometheus", "console", "logging"] =
      throwE $
        "Environment variable " <> optionName <> " specifies unsupported exporter '" <> exporterType <> "'."
  | otherwise =
      throwE $
        "Environment variable " <> optionName <> " specifies unknown exporter '" <> exporterType <> "'."

{- |
Internal helper.

Check if all elements are the same.
-}
allSame :: (Eq a) => [a] -> Bool
allSame [] = True
allSame (x : xs) = all (== x) xs