packages feed

otel-effectful-1.0.0: src/Effectful/OpenTelemetry/Protocol/Environment.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# OPTIONS_GHC -Wno-name-shadowing #-}

module Effectful.OpenTelemetry.Protocol.Environment {-# WARNING in "x-unstable-interface" "This is an unstable interface." #-} where

import Control.Applicative ((<|>))
import Control.Exception (Exception (..))
import Data.Aeson.Types (Pair, Value (String))
import Data.Functor ((<&>))
import Data.List (intercalate)
import Data.List.Extra (splitOn, trim)
import Data.Maybe (fromMaybe)
import Data.String (IsString (fromString))
import Data.Text qualified as Text
import Effectful
import Effectful.Environment (Environment, lookupEnv)
import Effectful.Error.Static (Error, runErrorNoCallStackWith, throwError)
import Effectful.Exception (throwIO)
import Effectful.OpenTelemetry.Protocol.Attributes (Attributes)
import Effectful.OpenTelemetry.Protocol.Attributes qualified as Attributes
import Effectful.OpenTelemetry.Protocol.Exception
    ( SomeOTLPException (..)
    , otlpExceptionFromException
    , otlpExceptionToException
    )
import Effectful.OpenTelemetry.Protocol.Export (Request (..))
import Effectful.OpenTelemetry.Protocol.Export qualified as Export
import Effectful.OpenTelemetry.Protocol.GRPC qualified as GRPC
import Effectful.OpenTelemetry.Protocol.HTTP qualified as HTTP
import Effectful.OpenTelemetry.Protocol.Resource (Resource (Resource))
import Effectful.OpenTelemetry.Protocol.Transport
    ( Compression (..)
    , Encoding (..)
    , Protocol (..)
    , Transport (..)
    )
import Network.URI (URI (..), URIAuth (..), parseAbsoluteURI)
import Network.URI qualified as URI
import Numeric.Natural (Natural)
import Safe (readMay)
import Text.Read (readMaybe)
import Prelude

data OTLPConfig = OTLPConfig {variable :: String, value :: String}
    deriving stock (Show, Eq)

data ConfigError
    = ParseEnvConfigError OTLPConfig
    | InvalidPort String
    | InvalidTimeout String
    | UnknownProtocol String
    | UnknownExporter String
    | InvalidOTLPEndpointURI String
    | OTLPEndpointMissingAuthority URI
    | UnsupportedOTLPCompression String
    | InvalidResourceAttribute String
    deriving stock (Show, Eq)

instance Exception ConfigError where
    toException = otlpExceptionToException
    fromException = otlpExceptionFromException
    displayException (ParseEnvConfigError OTLPConfig{..}) =
        "Error parsing " <> variable <> ". Invalid value: " <> value
    displayException (InvalidPort port) = "Invalid port number: " <> port
    displayException (InvalidTimeout timeout) = "Invalid timeout: " <> timeout
    displayException (UnknownProtocol protocol) =
        "Unknown OTEL_EXPORTER_OTLP_PROTOCOL: " <> protocol
    displayException (UnknownExporter name) =
        "Unknown exporter in OTEL_*_EXPORTER: " <> name
    displayException (InvalidOTLPEndpointURI uri) =
        "Invalid OTLP endpoint URI: " <> uri
    displayException (OTLPEndpointMissingAuthority uri) =
        "OTLP endpoint missing authority: " <> show uri
    displayException (UnsupportedOTLPCompression compression) =
        "Unsupported OTLP compression: " <> compression
    displayException (InvalidResourceAttribute pair) =
        "Invalid OTEL_RESOURCE_ATTRIBUTES entry (expected key=value): " <> pair

isSdkDisabled :: (Environment :> es) => Eff es Bool
isSdkDisabled = lookupEnv "OTEL_SDK_DISABLED" <&> (== Just "true")

runConfigError :: Eff (Error ConfigError ': es) a -> Eff es a
runConfigError = runErrorNoCallStackWith (throwIO . SomeOTLPException @ConfigError)

-- | The name of the signal-specific @OTEL_EXPORTER_OTLP_\<SIGNAL\>_*@ variable
-- for signal @a@.
signalEnvName :: forall a. (Export.Request a) => String -> String
signalEnvName suffix = intercalate "_" [exporterEnvPrefix, transportSignalEnvName @a, suffix]

-- | The name of the signal-agnostic @OTEL_EXPORTER_OTLP_*@ variable.
globalEnvName :: String -> String
globalEnvName suffix = intercalate "_" [exporterEnvPrefix, suffix]

exporterEnvPrefix :: String
exporterEnvPrefix = "OTEL_EXPORTER_OTLP"

-- | Read an @OTEL_EXPORTER_OTLP_*@ setting for signal @a@, preferring the
-- signal-specific variable over the global one.
lookupSignalOrGlobal
    :: forall a es
     . (Export.Request a, Environment :> es)
    => String
    -> Eff es (Maybe String)
lookupSignalOrGlobal suffix = do
    signal <- lookupEnv $ signalEnvName @a suffix
    global <- lookupEnv $ globalEnvName suffix
    pure $ signal <|> global

-- | The configured 'Compression' for signal @a@, from
-- @OTEL_EXPORTER_OTLP_<SIGNAL>_COMPRESSION@ or @OTEL_EXPORTER_OTLP_COMPRESSION@.
-- Defaults to 'NoCompression'.
lookupCompression
    :: forall a es
     . (Export.Request a, Environment :> es, Error ConfigError :> es)
    => Eff es Compression
lookupCompression =
    lookupSignalOrGlobal @a "COMPRESSION" >>= \case
        Nothing -> pure NoCompression
        Just name -> maybe (throwError $ UnsupportedOTLPCompression name) pure $ readMaybe name

-- | Detect a 'Resource' from the standard OpenTelemetry environment variables:
-- @OTEL_SERVICE_NAME@ and @OTEL_RESOURCE_ATTRIBUTES@.
-- @OTEL_SERVICE_NAME@ takes precedence over a @service.name@ set via @OTEL_RESOURCE_ATTRIBUTES@.
--
-- See <https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/#general-sdk-configuration the OpenTelemetry spec>.
detectResource :: (Environment :> es, Error ConfigError :> es) => Eff es Resource
detectResource = do
    fromAttributes <-
        lookupEnv "OTEL_RESOURCE_ATTRIBUTES"
            >>= \case
                Just (trim -> s) | not (null s) -> either throwError (pure . Resource) $ parseResourceAttributes s
                _ -> pure mempty
    fromServiceName <-
        lookupEnv "OTEL_SERVICE_NAME"
            <&> \case
                Just (trim -> s) | not (null s) -> Resource $ Attributes.fromList [("service.name", String $ Text.pack s)]
                _ -> mempty
    pure $ fromServiceName <> fromAttributes
  where
    parseResourceAttributes :: String -> Either ConfigError Attributes
    parseResourceAttributes =
        fmap Attributes.fromList
            . mapM parsePair
            . filter (not . null)
            . map trim
            . splitOn ","
    parsePair :: String -> Either ConfigError Pair
    parsePair s = case break (== '=') s of
        (key, '=' : value) -> Right (decode key, decode value)
        _ -> Left $ InvalidResourceAttribute s
    decode :: (IsString s) => String -> s
    decode = fromString . URI.unEscapeString . trim

lookupTransport
    :: forall a es
     . ( Export.Request a
       , Error ConfigError :> es
       , Environment :> es
       )
    => Eff es Transport
lookupTransport = do
    protocol <- lookupProtocol
    compression <- lookupCompression @a
    timeoutStr <- lookupWithFallback "TIMEOUT" "30000"
    timeoutMs <- maybe (throwError $ InvalidTimeout timeoutStr) pure $ readMay timeoutStr
    pure Transport{..}
  where
    lookupProtocol =
        lookupWithFallback "PROTOCOL" "http/protobuf" >>= \case
            "http/protobuf" -> do
                HTTP Proto <$> lookupHttpUri
            "http/json" -> do
                HTTP Json <$> lookupHttpUri
            "grpc" -> do
                uri <- lookupGrpcUri
                (host, port) <- case uri of
                    URI{uriAuthority = Just URIAuth{uriRegName, ..}}
                        | ':' : (readMay -> Just port) <- uriPort -> pure (uriRegName, port)
                        | otherwise -> throwError $ InvalidPort uriPort
                    _ -> throwError $ OTLPEndpointMissingAuthority uri
                pure . GRPC host port $ exportGrpcRPC @a
            other -> throwError $ UnknownProtocol other

    lookupEndpoint :: Eff es (Maybe URI, Maybe URI)
    lookupEndpoint = do
        signal <- parseOrFail =<< lookupEnv (signalEnvName @a "ENDPOINT")
        global <- parseOrFail =<< lookupEnv (globalEnvName "ENDPOINT")
        pure (signal, global)

    lookupHttpUri :: Eff es URI
    lookupHttpUri = do
        lookupEndpoint <&> \case
            (Just endpoint, _) -> rootPathIfEmpty endpoint
            (_, Just endpoint) -> appendExportPath @a endpoint
            _ -> appendExportPath @a HTTP.defaultEndpoint

    lookupGrpcUri :: Eff es URI
    lookupGrpcUri = do
        lookupEndpoint <&> \case
            (Just endpoint, _) -> endpoint
            (_, Just endpoint) -> endpoint
            _ -> GRPC.defaultEndpoint

    lookupWithFallback :: String -> String -> Eff es String
    lookupWithFallback suffix fallback =
        fromMaybe fallback <$> lookupSignalOrGlobal @a suffix

    rootPathIfEmpty :: URI -> URI
    rootPathIfEmpty uri
        | null (uriPath uri) = uri{uriPath = "/"}
        | otherwise = uri

    parseOrFail :: Maybe String -> Eff es (Maybe URI)
    parseOrFail Nothing = pure Nothing
    parseOrFail (Just s) = maybe (throwError $ InvalidOTLPEndpointURI s) (pure . pure) $ parseAbsoluteURI s

lookupExportConfig
    :: forall a es
     . ( Export.Request a
       , Environment :> es
       , Error ConfigError :> es
       )
    => Export.Config
    -> Eff es Export.Config
lookupExportConfig Export.Config{..} = do
    batch <-
        maybe (pure Nothing) (fmap Just . uncurry lookupBatchConfig) $
            (,) <$> batchEnvPrefix @a <*> batch
    exportTimeoutMs <- envNatural "EXPORT_TIMEOUT" exportTimeoutMs
    pure Export.Config{..}
  where
    envNatural :: String -> Natural -> Eff es Natural
    envNatural name fallback = lookupEnv variable >>= maybe (pure fallback) parse
      where
        variable = intercalate "_" ["OTEL", exportSignalEnvName @a, name]
        parse value = maybe (throwError $ ParseEnvConfigError OTLPConfig{..}) pure $ readMay value

    lookupBatchConfig :: String -> Export.BatchConfig -> Eff es Export.BatchConfig
    lookupBatchConfig batchEnvPrefix Export.BatchConfig{..} = do
        maxQueueSize <- envNatural "MAX_QUEUE_SIZE" maxQueueSize
        scheduledDelayMs <- envNatural "SCHEDULE_DELAY" scheduledDelayMs
        maxBatchSize <- envNatural "MAX_EXPORT_BATCH_SIZE" maxBatchSize
        pure Export.BatchConfig{..}
      where
        envNatural :: String -> Natural -> Eff es Natural
        envNatural name fallback = lookupEnv variable >>= maybe (pure fallback) parse
          where
            variable = intercalate "_" ["OTEL", batchEnvPrefix, name]
            parse value = maybe (throwError $ ParseEnvConfigError OTLPConfig{..}) pure $ readMay value