packages feed

hs-opentelemetry-sdk-1.0.0.0: src/OpenTelemetry/Configuration/Parse.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}

{- | YAML configuration file parser with environment variable substitution.
Implements the "Parse" operation from the OpenTelemetry Configuration SDK spec.
See: https://opentelemetry.io/docs/specs/otel/configuration/sdk/#parse
-}
module OpenTelemetry.Configuration.Parse (
  parseConfigFile,
  parseConfigBytes,
  ConfigParseError (..),
) where

import Control.Exception (Exception, try)
import Data.Aeson (Object, Value (..))
import qualified Data.Aeson.Key as AesonKey
import qualified Data.Aeson.KeyMap as KM
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Scientific (toBoundedInteger, toRealFloat)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.IO as TIO
import qualified Data.Text.Lazy as TL
import Data.Text.Lazy.Builder (toLazyText)
import Data.Text.Lazy.Builder.Int (decimal)
import Data.Text.Lazy.Builder.RealFloat (realFloat)
import qualified Data.Vector as V
import qualified Data.Yaml as Yaml
import OpenTelemetry.Configuration.Types
import OpenTelemetry.Internal.Logging (otelLogWarning)
import System.Environment (lookupEnv)


-- | @since 0.1.0.0
data ConfigParseError
  = ConfigFileNotFound !FilePath
  | ConfigYamlError !Text
  | ConfigValidationError !Text
  deriving (Show, Eq)


instance Exception ConfigParseError


{- | Parse a configuration file from a file path.
Performs environment variable substitution and validates the result.

@since 0.1.0.0
-}
parseConfigFile :: FilePath -> IO (Either ConfigParseError OTelConfiguration)
parseConfigFile path = do
  contentResult <- try (TIO.readFile path)
  case contentResult of
    Left (_ :: IOError) -> pure $ Left $ ConfigFileNotFound path
    Right content -> parseConfigBytes content


{- | Parse configuration from YAML text content.

@since 0.1.0.0
-}
parseConfigBytes :: Text -> IO (Either ConfigParseError OTelConfiguration)
parseConfigBytes content = do
  substituted <- substituteEnvVars content
  case parseYamlToValue substituted of
    Left err -> pure $ Left $ ConfigYamlError err
    Right val -> pure $ valueToConfig val


parseYamlToValue :: Text -> Either Text Value
parseYamlToValue input =
  case Yaml.decodeEither' (TE.encodeUtf8 input) of
    Left err -> Left (T.pack (Yaml.prettyPrintParseException err))
    Right val -> Right val


{- | Substitute environment variable references in text.
Supports: ${VAR}, ${env:VAR}, ${VAR:-default}, ${env:VAR:-default}
-}
substituteEnvVars :: Text -> IO Text
substituteEnvVars input = go input T.empty
  where
    go remaining acc
      | T.null remaining = pure acc
      | otherwise =
          case T.breakOn "${" remaining of
            (before, "") -> pure (acc <> before)
            (before, withRef) ->
              let afterOpen = T.drop 2 withRef
              in case T.breakOn "}" afterOpen of
                   (_, "") -> do
                     otelLogWarning $ "Malformed environment variable reference in config (no closing '}'): " <> T.unpack withRef
                     pure (acc <> remaining)
                   (ref, afterClose) -> do
                     val <- resolveRef ref
                     go (T.drop 1 afterClose) (acc <> before <> val)

    resolveRef ref =
      let (varSpec, defaultVal) = splitDefault ref
          varName = T.strip $ case T.stripPrefix "env:" varSpec of
            Just name -> name
            Nothing -> varSpec
      in do
           result <- lookupEnv (T.unpack varName)
           pure $ case result of
             Just v -> T.pack v
             Nothing -> defaultVal

    splitDefault ref =
      case T.breakOn ":-" ref of
        (v, "") -> (v, T.empty)
        (v, rest) -> (v, T.drop 2 rest)


-- | Convert a parsed JSON Value to an OTelConfiguration.
valueToConfig :: Value -> Either ConfigParseError OTelConfiguration
valueToConfig (Object obj) = do
  let fileFormat = getTextOr "file_format" "1.0" obj
      disabled = getBoolMaybe "disabled" obj
      attrLimits = case KM.lookup "attribute_limits" obj of
        Just (Object al) ->
          Just
            AttributeLimitsConfig
              { alAttributeValueLengthLimit = getIntMaybe "attribute_value_length_limit" al
              , alAttributeCountLimit = getIntMaybe "attribute_count_limit" al
              }
        _ -> Nothing
      resource = case KM.lookup "resource" obj of
        Just (Object r) ->
          Just
            ResourceConfig
              { resourceAttributes = case KM.lookup "attributes" r of
                  Just (Object attrs) -> Just $ objectToTextMap attrs
                  _ -> Nothing
              , resourceDetectors = case KM.lookup "detectors" r of
                  Just (Array arr) -> Just $ V.toList $ V.mapMaybe getText arr
                  _ -> Nothing
              , resourceSchemaUrl = getTextMaybe "schema_url" r
              }
        _ -> Nothing
      propagator = case KM.lookup "propagator" obj of
        Just (Object p) ->
          Just
            PropagatorConfig
              { propagatorComposite = case KM.lookup "composite" p of
                  Just (Array arr) -> Just $ V.toList $ V.mapMaybe getText arr
                  _ -> Nothing
              }
        _ -> Nothing
      tracerProvider = case KM.lookup "tracer_provider" obj of
        Just (Object tp) -> Just $ parseTracerProvider tp
        _ -> Nothing
      meterProvider = case KM.lookup "meter_provider" obj of
        Just (Object mp) -> Just $ parseMeterProvider mp
        _ -> Nothing
      loggerProvider = case KM.lookup "logger_provider" obj of
        Just (Object lp) -> Just $ parseLoggerProvider lp
        _ -> Nothing
  Right
    OTelConfiguration
      { configFileFormat = fileFormat
      , configDisabled = disabled
      , configAttributeLimits = attrLimits
      , configResource = resource
      , configPropagator = propagator
      , configTracerProvider = tracerProvider
      , configMeterProvider = meterProvider
      , configLoggerProvider = loggerProvider
      }
valueToConfig _ = Left $ ConfigValidationError "Top-level configuration must be a YAML mapping"


parseTracerProvider :: Object -> TracerProviderConfig
parseTracerProvider obj =
  TracerProviderConfig
    { tpProcessors = case KM.lookup "processors" obj of
        Just (Array arr) -> Just $ V.toList $ V.map parseSpanProcessor arr
        _ -> Nothing
    , tpSampler = case KM.lookup "sampler" obj of
        Just (Object s) -> parseSamplerConfig s
        _ -> Nothing
    , tpLimits = case KM.lookup "limits" obj of
        Just (Object l) ->
          Just
            SpanLimitsConfig
              { slAttributeValueLengthLimit = getIntMaybe "attribute_value_length_limit" l
              , slAttributeCountLimit = getIntMaybe "attribute_count_limit" l
              , slEventCountLimit = getIntMaybe "event_count_limit" l
              , slLinkCountLimit = getIntMaybe "link_count_limit" l
              , slEventAttributeCountLimit = getIntMaybe "event_attribute_count_limit" l
              , slLinkAttributeCountLimit = getIntMaybe "link_attribute_count_limit" l
              }
        _ -> Nothing
    }


parseSpanProcessor :: Value -> SpanProcessorConfig
parseSpanProcessor (Object obj) = case KM.lookup "batch" obj of
  Just (Object b) ->
    SpanProcessorBatch
      BatchSpanProcessorConfig
        { bspScheduleDelay = getIntMaybe "schedule_delay" b
        , bspExportTimeout = getIntMaybe "export_timeout" b
        , bspMaxQueueSize = getIntMaybe "max_queue_size" b
        , bspMaxExportBatchSize = getIntMaybe "max_export_batch_size" b
        , bspExporter = parseSpanExporter b
        }
  _ -> case KM.lookup "simple" obj of
    Just (Object s) ->
      SpanProcessorSimple
        SimpleSpanProcessorConfig
          { sspExporter = parseSpanExporter s
          }
    _ -> SpanProcessorUnknown
parseSpanProcessor _ = SpanProcessorUnknown


parseSpanExporter :: Object -> SpanExporterConfig
parseSpanExporter obj = case KM.lookup "exporter" obj of
  Just (Object e) -> parseExporterChoice e SpanExporterOtlpHttp SpanExporterConsole SpanExporterNone
  _ -> SpanExporterNone


parseExporterChoice
  :: Object
  -> (OtlpHttpExporterConfig -> a)
  -> (ConsoleExporterConfig -> a)
  -> a
  -> a
parseExporterChoice obj mkOtlp mkConsole fallback
  | Just v <- KM.lookup "otlp_http" obj = mkOtlp (parseOtlpConfig v)
  | Just _ <- KM.lookup "console" obj = mkConsole ConsoleExporterConfig
  | otherwise = fallback


parseOtlpConfig :: Value -> OtlpHttpExporterConfig
parseOtlpConfig (Object o) =
  OtlpHttpExporterConfig
    { otlpCfgEndpoint = getTextMaybe "endpoint" o
    , otlpSignalEndpoint = Nothing
    , otlpCfgTimeout = getIntMaybe "timeout" o
    , otlpCfgCompression = getTextMaybe "compression" o
    , otlpCfgHeaders = case KM.lookup "headers" o of
        Just (Object h) -> Just $ objectToTextMap h
        _ -> Nothing
    }
parseOtlpConfig Null =
  OtlpHttpExporterConfig Nothing Nothing Nothing Nothing Nothing
parseOtlpConfig _ =
  OtlpHttpExporterConfig Nothing Nothing Nothing Nothing Nothing


parseSamplerConfig :: Object -> Maybe SamplerConfig
parseSamplerConfig obj
  | Just _ <- KM.lookup "always_on" obj = Just SamplerAlwaysOn
  | Just _ <- KM.lookup "always_off" obj = Just SamplerAlwaysOff
  | Just (Object r) <- KM.lookup "trace_id_ratio_based" obj =
      case getDoubleMaybe "ratio" r of
        Just ratio -> Just $ SamplerTraceIdRatioBased (TraceIdRatioSamplerConfig ratio)
        Nothing -> Nothing
  | Just (Object p) <- KM.lookup "parent_based" obj =
      Just $
        SamplerParentBased
          ParentBasedSamplerConfig
            { pbRoot = case KM.lookup "root" p of
                Just (Object s) -> parseSamplerConfig s
                _ -> Nothing
            , pbRemoteParentSampled = case KM.lookup "remote_parent_sampled" p of
                Just (Object s) -> parseSamplerConfig s
                _ -> Nothing
            , pbRemoteParentNotSampled = case KM.lookup "remote_parent_not_sampled" p of
                Just (Object s) -> parseSamplerConfig s
                _ -> Nothing
            , pbLocalParentSampled = case KM.lookup "local_parent_sampled" p of
                Just (Object s) -> parseSamplerConfig s
                _ -> Nothing
            , pbLocalParentNotSampled = case KM.lookup "local_parent_not_sampled" p of
                Just (Object s) -> parseSamplerConfig s
                _ -> Nothing
            }
  | otherwise = Nothing


parseMeterProvider :: Object -> MeterProviderConfig
parseMeterProvider obj =
  MeterProviderConfig
    { mpReaders = case KM.lookup "readers" obj of
        Just (Array arr) -> Just $ V.toList $ V.map parseMetricReader arr
        _ -> Nothing
    }


parseMetricReader :: Value -> MetricReaderConfig
parseMetricReader (Object obj) = case KM.lookup "periodic" obj of
  Just (Object p) ->
    MetricReaderPeriodic
      PeriodicMetricReaderConfig
        { pmrInterval = getIntMaybe "interval" p
        , pmrTimeout = getIntMaybe "timeout" p
        , pmrExporter = parsePushMetricExporter p
        }
  _ -> MetricReaderPeriodic (PeriodicMetricReaderConfig Nothing Nothing PushMetricExporterNone)
parseMetricReader _ = MetricReaderPeriodic (PeriodicMetricReaderConfig Nothing Nothing PushMetricExporterNone)


parsePushMetricExporter :: Object -> PushMetricExporterConfig
parsePushMetricExporter obj = case KM.lookup "exporter" obj of
  Just (Object e) -> parseExporterChoice e PushMetricExporterOtlpHttp PushMetricExporterConsole PushMetricExporterNone
  _ -> PushMetricExporterNone


parseLoggerProvider :: Object -> LoggerProviderConfig
parseLoggerProvider obj =
  LoggerProviderConfig
    { lpProcessors = case KM.lookup "processors" obj of
        Just (Array arr) -> Just $ V.toList $ V.map parseLogRecordProcessor arr
        _ -> Nothing
    }


parseLogRecordProcessor :: Value -> LogRecordProcessorConfig
parseLogRecordProcessor (Object obj) = case KM.lookup "batch" obj of
  Just (Object b) ->
    LogRecordProcessorBatch
      BatchLogRecordProcessorConfig
        { blpScheduleDelay = getIntMaybe "schedule_delay" b
        , blpExportTimeout = getIntMaybe "export_timeout" b
        , blpMaxQueueSize = getIntMaybe "max_queue_size" b
        , blpMaxExportBatchSize = getIntMaybe "max_export_batch_size" b
        , blpExporter = parseLogRecordExporter b
        }
  _ -> case KM.lookup "simple" obj of
    Just (Object s) ->
      LogRecordProcessorSimple
        SimpleLogRecordProcessorConfig
          { slpExporter = parseLogRecordExporter s
          }
    _ -> LogRecordProcessorUnknown
parseLogRecordProcessor _ = LogRecordProcessorUnknown


parseLogRecordExporter :: Object -> LogRecordExporterConfig
parseLogRecordExporter obj = case KM.lookup "exporter" obj of
  Just (Object e) -> parseExporterChoice e LogRecordExporterOtlpHttp LogRecordExporterConsole LogRecordExporterNone
  _ -> LogRecordExporterNone


-- Helpers

objectToTextMap :: Object -> Map Text Text
objectToTextMap = KM.foldrWithKey (\k v m -> Map.insert (AesonKey.toText k) (valueToText v) m) Map.empty


valueToText :: Value -> Text
valueToText (String t) = t
valueToText (Number n) = case toBoundedInteger n :: Maybe Int of
  Just i -> TL.toStrict $ toLazyText $ decimal i
  Nothing -> TL.toStrict $ toLazyText $ realFloat (toRealFloat n :: Double)
valueToText (Bool True) = "true"
valueToText (Bool False) = "false"
valueToText Null = ""
valueToText _ = ""


getText :: Value -> Maybe Text
getText (String t) = Just t
getText _ = Nothing


getTextMaybe :: Text -> Object -> Maybe Text
getTextMaybe key obj = KM.lookup (AesonKey.fromText key) obj >>= getText


getTextOr :: Text -> Text -> Object -> Text
getTextOr key def obj = case getTextMaybe key obj of
  Just v -> v
  Nothing -> def


getIntMaybe :: Text -> Object -> Maybe Int
getIntMaybe key obj = case KM.lookup (AesonKey.fromText key) obj of
  Just (Number n) -> toBoundedInteger n
  _ -> Nothing


getDoubleMaybe :: Text -> Object -> Maybe Double
getDoubleMaybe key obj = case KM.lookup (AesonKey.fromText key) obj of
  Just (Number n) -> Just (toRealFloat n)
  _ -> Nothing


getBoolMaybe :: Text -> Object -> Maybe Bool
getBoolMaybe key obj = case KM.lookup (AesonKey.fromText key) obj of
  Just (Bool b) -> Just b
  _ -> Nothing