packages feed

otel-effectful-1.0.0: src/Effectful/OpenTelemetry/Metrics/Measurement.hs

{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}

module Effectful.OpenTelemetry.Metrics.Measurement where

import Data.Aeson.KeyMap qualified as KeyMap
import Data.Aeson.Types (Key, ToJSON (..), Value (..), object, (.=))
import Data.Foldable qualified as Foldable
import Data.Functor ((<&>))
import Data.Scientific (Scientific)
import Data.Scientific qualified as Scientific
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Typeable (Typeable, cast)
import Data.Word (Word64)
import Effectful.OpenTelemetry.Metrics.Metadata (Metadata (..))
import Effectful.OpenTelemetry.Protocol.Attributes (Attributes)
import Effectful.OpenTelemetry.Protocol.Export qualified as Export
import Effectful.OpenTelemetry.Protocol.Resource qualified as Resource
import Effectful.OpenTelemetry.Protocol.Scope qualified as Scope
import Effectful.OpenTelemetry.Timestamp (Timestamp)
import GHC.Generics (Generic)
import Network.GRPC.HTTP2.Proto3Wire (RPC (..))
import Prettyprinter (Pretty (..))
import Prettyprinter.Extra (PrettyAnn (..))
import Prettyprinter.Extra qualified as Pretty
import Proto3.Wire.Encode.Class qualified as Proto
import Prelude

-- | Represents a data point reported via the OpenTelemetry metrics API.
--
-- See <https://opentelemetry.io/docs/specs/otel/metrics/api/#measurement the OpenTelemetry spec>.
data Measurement where
    Measurement
        :: (Metric d)
        => { name :: Text
           , description :: Maybe Text
           , unit :: Maybe Text
           , metric :: d
           }
        -> Measurement

instance Show Measurement where
    show Measurement{..} =
        "Measurement{name="
            <> show name
            <> ",description="
            <> show description
            <> ",unit="
            <> show unit
            <> ",metric="
            <> show (toJSON metric)
            <> "}"

instance ToJSON Measurement where
    toJSON Measurement{..} =
        object
            [ "name" .= name
            , "description" .= description
            , "unit" .= unit
            , metricJsonKey metric .= metric
            ]

-- https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/metrics/v1/metrics.proto
instance Proto.Encode Measurement where
    encode Measurement{..} =
        mconcat
            [ Proto.encodeField 1 name
            , foldMap (Proto.encodeField 2) description
            , foldMap (Proto.encodeField 3) unit
            , Proto.encodeField (metricProtoFieldNumber metric) metric
            ]

instance PrettyAnn ann Measurement where
    prettyAnn Measurement{..} =
        Pretty.unwords . filter (not . Pretty.null) $
            [ pretty name <> ":"
            , case toJSON metric of
                Object (KeyMap.lookup "dataPoints" -> Just (Array dataPoints)) ->
                    Pretty.intercalate ", " $
                        Foldable.toList dataPoints <&> \case
                            Object dataPoint ->
                                prettyAnn . Object $
                                    KeyMap.filterWithKey
                                        (\key _ -> key `elem` ["asDouble", "asInt", "count", "max", "min", "sum"])
                                        dataPoint
                            val -> prettyAnn val
                val -> prettyAnn val
            , maybe "" pretty unit
            ]

instance Export.Request Measurement where
    exportHttpPathComponents = ["v1", "metrics"]
    exportGrpcRPC =
        RPC
            { pkg = "opentelemetry.proto.collector.metrics.v1"
            , srv = "MetricsService"
            , meth = "Export"
            }
    exportJson = object . pure . ("resourceMetrics" .=) . fmap resourceMetrics
      where
        resourceMetrics :: Resource.Items Measurement -> Value
        resourceMetrics Resource.Items{..} =
            object
                [ "resource" .= resource
                , "scopeMetrics" .= fmap scopeMetrics scopeItems
                ]
        scopeMetrics :: Scope.Items Measurement -> Value
        scopeMetrics Scope.Items{..} =
            object
                [ "scope" .= scope
                , "metrics" .= items
                ]

    transportSignalEnvName = "METRICS"
    exportSignalEnvName = "METRIC"
    batchEnvPrefix = Nothing

    defaultConfig =
        Export.Config
            { batch = Nothing
            , exportTimeoutMs = 30_000
            }

-- | Build a 'Measurement' for a metric of the given name, taking its
-- description and unit from the instrument's 'Metadata'.
create :: (Metric d) => Text -> Metadata -> d -> Measurement
create name Metadata{..} metric = Measurement{..}

-- | How to embed a metric in an OTLP payload.
class (Typeable d, ToJSON d, Proto.Encode d) => Metric d where
    -- | JSON field name under which this metric is nested.
    metricJsonKey :: d -> Key

    -- | Proto field number under which this metric is nested.
    metricProtoFieldNumber :: d -> Proto.FieldNumber

-- | Recover a 'Measurement''s underlying metric, if it is of the given type.
toMetric :: (Metric d) => Measurement -> Maybe d
toMetric Measurement{metric} = cast metric

-- | A single data point in a timeseries that describes the time-varying
-- scalar value of a metric.
data NumberDataPoint = NumberDataPoint
    { attributes :: Attributes
    , startTime :: Timestamp
    , time :: Timestamp
    , value :: Scientific
    }
    deriving stock (Generic, Show, Eq)

instance ToJSON NumberDataPoint where
    toJSON dp =
        object $
            [ "attributes" .= dp.attributes
            , "startTimeUnixNano" .= dp.startTime
            , "timeUnixNano" .= dp.time
            ]
                <> case Scientific.floatingOrInteger @Double @Integer dp.value of
                    Left _ -> ["asDouble" .= dp.value]
                    Right i -> ["asInt" .= Text.show i]

instance Proto.Encode NumberDataPoint where
    encode dp =
        mconcat
            [ Proto.encodeField 7 dp.attributes
            , Proto.encodeField 2 dp.startTime
            , Proto.encodeField 3 dp.time
            , either (Proto.double 4) (Proto.sfixed64 6) $
                Scientific.floatingOrInteger dp.value
            ]

-- | A single data point in a timeseries that describes the time-varying values of a histogram.
data HistogramDataPoint = HistogramDataPoint
    { attributes :: Attributes
    , startTime :: Timestamp
    , time :: Timestamp
    , count :: Word64
    , sum :: Maybe Scientific
    , bucketCounts :: [Word64]
    , explicitBounds :: [Scientific]
    , minValue :: Maybe Scientific
    , maxValue :: Maybe Scientific
    }
    deriving stock (Generic, Show, Eq)

instance ToJSON HistogramDataPoint where
    toJSON dp =
        object $
            [ "attributes" .= dp.attributes
            , "startTimeUnixNano" .= dp.startTime
            , "timeUnixNano" .= dp.time
            , "count" .= Text.show dp.count
            , "bucketCounts" .= (Text.show <$> dp.bucketCounts)
            , "explicitBounds" .= dp.explicitBounds
            ]
                <> ["sum" .= v | Just v <- [dp.sum]]
                <> ["min" .= v | Just v <- [dp.minValue]]
                <> ["max" .= v | Just v <- [dp.maxValue]]

instance Proto.Encode HistogramDataPoint where
    encode dp =
        mconcat
            [ Proto.encodeField 2 dp.startTime
            , Proto.encodeField 3 dp.time
            , Proto.fixed64 4 dp.count
            , foldMap (Proto.double 5 . Scientific.toRealFloat) dp.sum
            , foldMap (Proto.fixed64 6) dp.bucketCounts
            , foldMap (Proto.double 7 . Scientific.toRealFloat) dp.explicitBounds
            , Proto.encodeField 9 dp.attributes
            , foldMap (Proto.double 11 . Scientific.toRealFloat) dp.minValue
            , foldMap (Proto.double 12 . Scientific.toRealFloat) dp.maxValue
            ]

-- | Defines how a metric aggregator reports aggregated values.
--
-- See <https://opentelemetry.io/docs/specs/otel/metrics/data-model/#temporality the OpenTelemetry spec>.
data AggregationTemporality
    = -- | Aggregator reports changes since /last report time/.
      -- This means that successive data points /advance/ the starting timestamp.
      TemporalityDelta
    | -- | Aggregator reports changes since /a fixed start time/.
      -- This means that successive data points /repeat/ the starting timestamp.
      TemporalityCumulative
    deriving stock (Generic, Show, Eq, Bounded)

instance Enum AggregationTemporality where
    toEnum 1 = TemporalityDelta
    toEnum 2 = TemporalityCumulative
    toEnum _ = error "Enum.AggregationTemporality.toEnum: bad argument"

    fromEnum TemporalityDelta = 1
    fromEnum TemporalityCumulative = 2

instance {-# OVERLAPPING #-} Proto.EncodeField AggregationTemporality where
    encodeField n = Proto.int32 n . fromIntegral . fromEnum