packages feed

hs-opentelemetry-sdk-1.0.0.0: test/OpenTelemetry/ConfigurationSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module OpenTelemetry.ConfigurationSpec (spec) where

import Control.Exception (bracket, bracket_)
import qualified Data.Map.Strict as Map
import Data.Text (Text, pack)
import OpenTelemetry.Attributes (lookupAttribute, toAttribute)
import OpenTelemetry.Configuration.Create (OTelSignals (..), createFromConfig)
import OpenTelemetry.Configuration.Parse (ConfigParseError (..), parseConfigBytes, parseConfigFile)
import OpenTelemetry.Configuration.Types
import OpenTelemetry.Propagator (Propagator (..))
import OpenTelemetry.Resource (getMaterializedResourcesAttributes)
import OpenTelemetry.Trace (detectSpanLimits)
import OpenTelemetry.Trace.Core (SpanLimits (..), getTracerProviderResources)
import System.Environment (lookupEnv, setEnv, unsetEnv)
import Test.Hspec


spec :: Spec
spec = do
  -- File configuration: declarative SDK configuration (YAML)
  -- https://opentelemetry.io/docs/specs/otel/configuration/
  describe "OpenTelemetry.Configuration.Parse.parseConfigBytes" $ do
    -- Configuration file_format and empty document defaults
    -- https://opentelemetry.io/docs/specs/otel/configuration/
    it "parses minimal valid config with defaults elsewhere" $ do
      let yaml :: Text
          yaml =
            "file_format: \"1.0\"\n"
      result <- parseConfigBytes yaml
      case result of
        Left err -> expectationFailure $ "expected Right, got Left: " ++ show err
        Right cfg -> do
          configFileFormat cfg `shouldBe` "1.0"
          cfg `shouldBe` emptyConfiguration

    -- Configuration: tracer_provider processors and sampler
    -- https://opentelemetry.io/docs/specs/otel/configuration/
    it "parses full tracer_provider with batch processor and always_on sampler" $ do
      let yaml :: Text
          yaml =
            "file_format: \"1.0\"\n\
            \tracer_provider:\n\
            \  processors:\n\
            \    - batch:\n\
            \        schedule_delay: 5000\n\
            \        exporter:\n\
            \          otlp_http:\n\
            \            endpoint: \"http://localhost:4318\"\n\
            \  sampler:\n\
            \    always_on: {}\n"
      result <- parseConfigBytes yaml
      case result of
        Left err -> expectationFailure $ "expected Right, got Left: " ++ show err
        Right cfg -> case configTracerProvider cfg of
          Nothing -> expectationFailure "expected configTracerProvider"
          Just tp -> do
            case tpProcessors tp of
              Nothing -> expectationFailure "expected tpProcessors"
              Just procs -> do
                length procs `shouldBe` 1
                case procs of
                  (SpanProcessorBatch bsp : _) -> do
                    bspScheduleDelay bsp `shouldBe` Just 5000
                    case bspExporter bsp of
                      SpanExporterOtlpHttp otlp ->
                        otlpCfgEndpoint otlp `shouldBe` Just "http://localhost:4318"
                      other ->
                        expectationFailure $ "expected SpanExporterOtlpHttp, got " ++ show other
                  _ ->
                    expectationFailure "expected SpanProcessorBatch as first processor"
            tpSampler tp `shouldBe` Just SamplerAlwaysOn

    -- Configuration: top-level disabled flag
    -- https://opentelemetry.io/docs/specs/otel/configuration/
    it "parses disabled: true" $ do
      let yaml :: Text
          yaml =
            "file_format: \"1.0\"\n\
            \disabled: true\n"
      result <- parseConfigBytes yaml
      case result of
        Left err -> expectationFailure $ "expected Right, got Left: " ++ show err
        Right cfg -> configDisabled cfg `shouldBe` Just True

    -- Configuration: resource.attributes map
    -- https://opentelemetry.io/docs/specs/otel/configuration/
    it "parses resource attributes map" $ do
      let yaml :: Text
          yaml =
            "file_format: \"1.0\"\n\
            \resource:\n\
            \  attributes:\n\
            \    service.name: my-service\n\
            \    service.version: \"1.0\"\n"
      result <- parseConfigBytes yaml
      case result of
        Left err -> expectationFailure $ "expected Right, got Left: " ++ show err
        Right cfg -> case configResource cfg of
          Nothing -> expectationFailure "expected configResource"
          Just res -> case resourceAttributes res of
            Nothing -> expectationFailure "expected resourceAttributes"
            Just attrs -> do
              Map.lookup "service.name" attrs `shouldBe` Just "my-service"
              Map.lookup "service.version" attrs `shouldBe` Just "1.0"

    -- Implementation-specific: parser surfaces YAML errors as ConfigYamlError
    it "returns ConfigYamlError for invalid YAML" $ do
      let garbage :: Text
          garbage = "this is not : valid ::: yaml [[[\n"
      result <- parseConfigBytes garbage
      case result of
        Right cfg -> expectationFailure $ "expected Left, got Right: " ++ show cfg
        Left err -> case err of
          ConfigYamlError _ -> pure ()
          other -> expectationFailure $ "expected ConfigYamlError, got " ++ show other

    -- Configuration: ${ENV} substitution in config file
    -- https://opentelemetry.io/docs/specs/otel/configuration/
    it "substitutes environment variables in YAML before parsing" $ do
      let varName :: String
          varName = "HS_OTEL_CONFIG_PARSE_TEST_SUBST_913847"
          yaml :: Text
          yaml =
            "file_format: \"1.0\"\n\
            \tracer_provider:\n\
            \  processors:\n\
            \    - batch:\n\
            \        exporter:\n\
            \          otlp_http:\n\
            \            endpoint: \"${HS_OTEL_CONFIG_PARSE_TEST_SUBST_913847}\"\n"
      bracketEnv varName "http://example.test:4318" $ do
        result <- parseConfigBytes yaml
        case result of
          Left err -> expectationFailure $ "expected Right, got Left: " ++ show err
          Right cfg -> case configTracerProvider cfg of
            Nothing -> expectationFailure "expected configTracerProvider"
            Just tp -> case tpProcessors tp of
              Nothing -> expectationFailure "expected tpProcessors"
              Just [] -> expectationFailure "expected non-empty processors"
              Just (SpanProcessorBatch bsp : _) -> case bspExporter bsp of
                SpanExporterOtlpHttp otlp ->
                  otlpCfgEndpoint otlp `shouldBe` Just "http://example.test:4318"
                other ->
                  expectationFailure $ "expected SpanExporterOtlpHttp, got " ++ show other
              Just (_ : _) ->
                expectationFailure "expected SpanProcessorBatch as first processor"

    -- Configuration: default value syntax for unset env vars
    -- https://opentelemetry.io/docs/specs/otel/configuration/
    it "uses default after :- when environment variable is unset" $ do
      let varName :: String
          varName = "HS_OTEL_CONFIG_PARSE_TEST_NONEXISTENT_774019"
          yaml :: Text
          yaml =
            "file_format: \"1.0\"\n\
            \tracer_provider:\n\
            \  processors:\n\
            \    - batch:\n\
            \        exporter:\n\
            \          otlp_http:\n\
            \            endpoint: \"${"
              <> pack varName
              <> ":-fallback_value}\"\n"
      unsetEnv varName
      result <- parseConfigBytes yaml
      case result of
        Left err -> expectationFailure $ "expected Right, got Left: " ++ show err
        Right cfg -> case configTracerProvider cfg of
          Nothing -> expectationFailure "expected configTracerProvider"
          Just tp -> case tpProcessors tp of
            Nothing -> expectationFailure "expected tpProcessors"
            Just (proc : _) -> case proc of
              SpanProcessorBatch bsp -> case bspExporter bsp of
                SpanExporterOtlpHttp otlp ->
                  otlpCfgEndpoint otlp `shouldBe` Just "fallback_value"
                other ->
                  expectationFailure $ "expected SpanExporterOtlpHttp, got " ++ show other
              other ->
                expectationFailure $ "expected SpanProcessorBatch, got " ++ show other
            Just [] -> expectationFailure "expected non-empty processors"

  describe "parseConfigBytes (branches)" $ do
    -- Configuration: meter_provider.readers (periodic + exporter)
    -- https://opentelemetry.io/docs/specs/otel/configuration/
    it "parses meter_provider with periodic reader and otlp_http exporter" $ do
      let yaml :: Text
          yaml =
            "file_format: \"1.0\"\n\
            \meter_provider:\n\
            \  readers:\n\
            \    - periodic:\n\
            \        interval: 30000\n\
            \        timeout: 5000\n\
            \        exporter:\n\
            \          otlp_http:\n\
            \            endpoint: \"http://localhost:4318\"\n"
      result <- parseConfigBytes yaml
      case result of
        Left err -> expectationFailure $ "expected Right, got Left: " ++ show err
        Right cfg -> case configMeterProvider cfg of
          Nothing -> expectationFailure "expected configMeterProvider"
          Just mp -> case mpReaders mp of
            Nothing -> expectationFailure "expected mpReaders"
            Just (MetricReaderPeriodic pr : _) -> do
              pmrInterval pr `shouldBe` Just 30000
              pmrTimeout pr `shouldBe` Just 5000
              case pmrExporter pr of
                PushMetricExporterOtlpHttp o ->
                  otlpCfgEndpoint o `shouldBe` Just "http://localhost:4318"
                other -> expectationFailure $ "expected PushMetricExporterOtlpHttp, got " ++ show other
            _ -> expectationFailure "expected MetricReaderPeriodic"

    -- Configuration: logger_provider processors
    -- https://opentelemetry.io/docs/specs/otel/configuration/
    it "parses logger_provider with batch processor" $ do
      let yaml :: Text
          yaml =
            "file_format: \"1.0\"\n\
            \logger_provider:\n\
            \  processors:\n\
            \    - batch:\n\
            \        schedule_delay: 1000\n\
            \        exporter:\n\
            \          console: {}\n"
      result <- parseConfigBytes yaml
      case result of
        Left err -> expectationFailure $ "expected Right, got Left: " ++ show err
        Right cfg -> case configLoggerProvider cfg of
          Nothing -> expectationFailure "expected configLoggerProvider"
          Just lp -> case lpProcessors lp of
            Nothing -> expectationFailure "expected lpProcessors"
            Just (LogRecordProcessorBatch blp : _) -> do
              blpScheduleDelay blp `shouldBe` Just 1000
              case blpExporter blp of
                LogRecordExporterConsole _ -> pure ()
                other -> expectationFailure $ "expected LogRecordExporterConsole, got " ++ show other
            _ -> expectationFailure "expected LogRecordProcessorBatch"

    -- Configuration: tracer_provider simple processor
    -- https://opentelemetry.io/docs/specs/otel/configuration/
    it "parses simple span processor" $ do
      let yaml :: Text
          yaml =
            "file_format: \"1.0\"\n\
            \tracer_provider:\n\
            \  processors:\n\
            \    - simple:\n\
            \        exporter:\n\
            \          console: {}\n"
      result <- parseConfigBytes yaml
      case result of
        Left err -> expectationFailure $ "expected Right, got Left: " ++ show err
        Right cfg -> case configTracerProvider cfg of
          Nothing -> expectationFailure "expected configTracerProvider"
          Just tp -> case tpProcessors tp of
            Nothing -> expectationFailure "expected tpProcessors"
            Just (SpanProcessorSimple ssp : _) -> case sspExporter ssp of
              SpanExporterConsole _ -> pure ()
              other -> expectationFailure $ "expected SpanExporterConsole, got " ++ show other
            _ -> expectationFailure "expected SpanProcessorSimple"

    -- Configuration: parent_based sampler
    -- https://opentelemetry.io/docs/specs/otel/configuration/
    it "parses parent_based sampler with nested always_on root" $ do
      let yaml :: Text
          yaml =
            "file_format: \"1.0\"\n\
            \tracer_provider:\n\
            \  sampler:\n\
            \    parent_based:\n\
            \      root:\n\
            \        always_on: {}\n"
      result <- parseConfigBytes yaml
      case result of
        Left err -> expectationFailure $ "expected Right, got Left: " ++ show err
        Right cfg -> case configTracerProvider cfg >>= tpSampler of
          Just (SamplerParentBased pb) -> pbRoot pb `shouldBe` Just SamplerAlwaysOn
          other -> expectationFailure $ "expected SamplerParentBased with root always_on, got " ++ show other

    -- Configuration: trace_id_ratio_based sampler
    -- https://opentelemetry.io/docs/specs/otel/configuration/
    it "parses trace_id_ratio_based sampler" $ do
      let yaml :: Text
          yaml =
            "file_format: \"1.0\"\n\
            \tracer_provider:\n\
            \  sampler:\n\
            \    trace_id_ratio_based:\n\
            \      ratio: 0.5\n"
      result <- parseConfigBytes yaml
      case result of
        Left err -> expectationFailure $ "expected Right, got Left: " ++ show err
        Right cfg -> case configTracerProvider cfg >>= tpSampler of
          Just (SamplerTraceIdRatioBased r) -> ratioValue r `shouldBe` 0.5
          other -> expectationFailure $ "expected SamplerTraceIdRatioBased, got " ++ show other

    -- Implementation-specific: validation rejects non-object root
    it "returns ConfigValidationError for non-object YAML" $ do
      result <- parseConfigBytes "hello\n"
      case result of
        Right cfg -> expectationFailure $ "expected Left, got Right: " ++ show cfg
        Left err -> case err of
          ConfigValidationError _ -> pure ()
          other -> expectationFailure $ "expected ConfigValidationError, got " ++ show other

    -- Implementation-specific: parseConfigFile IO error surface
    it "returns ConfigFileNotFound for missing file" $ do
      result <- parseConfigFile "/nonexistent/hs_opentelemetry_config_missing_01928374.yaml"
      case result of
        Right cfg -> expectationFailure $ "expected Left, got Right: " ++ show cfg
        Left err -> err `shouldBe` ConfigFileNotFound "/nonexistent/hs_opentelemetry_config_missing_01928374.yaml"

    -- Implementation-specific: substitution edge cases
    it "env substitution: malformed ${...} without closing brace preserves literal" $ do
      let yaml :: Text
          yaml =
            "file_format: '${UNCLOSED'\n"
      result <- parseConfigBytes yaml
      case result of
        Left err -> expectationFailure $ "expected Right, got Left: " ++ show err
        Right cfg -> configFileFormat cfg `shouldBe` "${UNCLOSED"

    -- Implementation-specific: empty ${} substitution
    it "env substitution: empty variable name ${} is handled gracefully" $ do
      let yaml :: Text
          yaml =
            "file_format: '${}'\n"
      result <- parseConfigBytes yaml
      case result of
        Left err -> expectationFailure $ "expected Right, got Left: " ++ show err
        Right cfg -> configFileFormat cfg `shouldBe` ""

    -- Configuration: tracer_provider.limits (span limits)
    -- https://opentelemetry.io/docs/specs/otel/configuration/
    it "parses span limits" $ do
      let yaml :: Text
          yaml =
            "file_format: \"1.0\"\n\
            \tracer_provider:\n\
            \  limits:\n\
            \    attribute_value_length_limit: 1\n\
            \    attribute_count_limit: 2\n\
            \    event_count_limit: 3\n\
            \    link_count_limit: 4\n\
            \    event_attribute_count_limit: 5\n\
            \    link_attribute_count_limit: 6\n"
      result <- parseConfigBytes yaml
      case result of
        Left err -> expectationFailure $ "expected Right, got Left: " ++ show err
        Right cfg -> case configTracerProvider cfg >>= tpLimits of
          Nothing -> expectationFailure "expected tpLimits"
          Just sl -> do
            slAttributeValueLengthLimit sl `shouldBe` Just 1
            slAttributeCountLimit sl `shouldBe` Just 2
            slEventCountLimit sl `shouldBe` Just 3
            slLinkCountLimit sl `shouldBe` Just 4
            slEventAttributeCountLimit sl `shouldBe` Just 5
            slLinkAttributeCountLimit sl `shouldBe` Just 6

  describe "OpenTelemetry.Configuration.Create.createFromConfig" $ do
    -- Configuration: SDK startup from parsed file (components graph)
    -- https://opentelemetry.io/docs/specs/otel/configuration/
    it "createFromConfig produces components from minimal config" $ do
      let yaml :: Text
          yaml = "file_format: \"1.0\"\n"
      ep <- parseConfigBytes yaml
      case ep of
        Left err -> expectationFailure $ "expected Right, got Left: " ++ show err
        Right cfg ->
          bracket (createFromConfig cfg) otelShutdown $ \comps -> do
            not (null (propagatorFields (otelPropagators comps))) `shouldBe` True

    -- Configuration: disabled SDK (no-op telemetry)
    -- https://opentelemetry.io/docs/specs/otel/configuration/
    it "createFromConfig with disabled=true returns noop components" $ do
      let yaml :: Text
          yaml =
            "file_format: \"1.0\"\n\
            \disabled: true\n"
      ep <- parseConfigBytes yaml
      case ep of
        Left err -> expectationFailure $ "expected Right, got Left: " ++ show err
        Right cfg ->
          bracket (createFromConfig cfg) otelShutdown $ \comps -> do
            propagatorFields (otelPropagators comps) `shouldBe` []

    -- Resource SDK: attributes from configuration merged into TracerProvider resource
    -- https://opentelemetry.io/docs/specs/otel/resource/sdk/
    it "createFromConfig resource attributes are propagated" $ do
      let yaml :: Text
          yaml =
            "file_format: \"1.0\"\n\
            \resource:\n\
            \  attributes:\n\
            \    service.name: my-service\n\
            \    service.version: \"1.0\"\n"
      ep <- parseConfigBytes yaml
      case ep of
        Left err -> expectationFailure $ "expected Right, got Left: " ++ show err
        Right cfg ->
          bracket (createFromConfig cfg) otelShutdown $ \comps -> do
            let attrs =
                  getMaterializedResourcesAttributes $
                    getTracerProviderResources $
                      otelTracerProvider comps
            lookupAttribute attrs "service.name" `shouldBe` Just (toAttribute ("my-service" :: Text))
            lookupAttribute attrs "service.version" `shouldBe` Just (toAttribute ("1.0" :: Text))

  describe "detectSpanLimits (environment)" $ sequential $ do
    -- General SDK environment variables for span limits (OTEL_SPAN_*)
    -- https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/
    it "maps OTEL_SPAN_LINK_COUNT_LIMIT and OTEL_EVENT_ATTRIBUTE_COUNT_LIMIT to distinct SpanLimits fields" $
      bracketUnset spanLimitEnvKeys $ \_ -> do
        setEnv "OTEL_SPAN_LINK_COUNT_LIMIT" "42"
        setEnv "OTEL_EVENT_ATTRIBUTE_COUNT_LIMIT" "77"
        sl <- detectSpanLimits
        linkCountLimit sl `shouldBe` Just 42
        eventAttributeCountLimit sl `shouldBe` Just 77


spanLimitEnvKeys :: [String]
spanLimitEnvKeys =
  [ "OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT"
  , "OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT"
  , "OTEL_SPAN_EVENT_COUNT_LIMIT"
  , "OTEL_EVENT_ATTRIBUTE_COUNT_LIMIT"
  , "OTEL_SPAN_LINK_COUNT_LIMIT"
  , "OTEL_LINK_ATTRIBUTE_COUNT_LIMIT"
  ]


bracketUnset :: [String] -> ([(String, Maybe String)] -> IO a) -> IO a
bracketUnset keys act =
  bracket
    ( do
        saved <- mapM (\k -> (,) k <$> lookupEnv k) keys
        mapM_ unsetEnv keys
        pure saved
    )
    (mapM_ restore)
    act
  where
    restore (k, Nothing) = unsetEnv k
    restore (k, Just v) = setEnv k v


bracketEnv :: String -> String -> IO a -> IO a
bracketEnv name value = bracket_ (setEnv name value) (unsetEnv name)