packages feed

eventlog-live-otelcol-0.7.0.0: src/GHC/Eventlog/Live/Otelcol/Exporter/Core.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE OverloadedStrings #-}

module GHC.Eventlog.Live.Otelcol.Exporter.Core (
  OtlpExporter (..),
  parseOtlpExporterOptions,
  withOtlpExporter,
  export,

  -- * Export via gRPC
  CanExportViaGrpc,

  -- * Export via HTTP/Protobuf
  CanExportViaHttpProtobuf (..),
  HttpError (..),
) where

import Control.Exception (Exception (..), throwIO)
import Control.Monad ((<=<))
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Char8 qualified as BSC
import Data.ByteString.Lazy qualified as BSL
import Data.CaseInsensitive qualified as CI
import Data.List qualified as L
import Data.Maybe (fromMaybe)
import Data.ProtoLens.Encoding qualified as Proto
import Data.ProtoLens.Message (Message (defMessage))
import Data.ProtoLens.Service.Types (HasMethodImpl (..))
import Data.Word (Word16)
import GHC.Eventlog.Live.Otelcol.Options (OtlpExporterOptions (..), OtlpProtocol (..))
import Network.GRPC.Client qualified as G
import Network.GRPC.Client.StreamType.IO qualified as G
import Network.GRPC.Common qualified as G
import Network.GRPC.Common.Protobuf (Protobuf, StreamingType (..))
import Network.GRPC.Common.Protobuf qualified as G
import Network.GRPC.Common.StreamType qualified as G
import Network.HTTP.Client qualified as H
import Network.HTTP.Client.TLS qualified as H
import Network.HTTP.Types.Header qualified as HTTP
import Network.HTTP.Types.Status qualified as HTTP
import Network.URI qualified as URI
import Text.Read (readMaybe)

--------------------------------------------------------------------------------
-- OTLP Exporter
--------------------------------------------------------------------------------

data OtlpExporter
  = OtlpExporterGrpc !OtlpGrpcExporter
  | OtlpExporterHttpProtobuf !OtlpHttpProtobufExporter

{- |
Construct an t`OtlpExporter` from t`OtlpExporterOptions`.
-}
withOtlpExporter :: OtlpExporterOptions OtlpEndpoint -> (OtlpExporter -> IO a) -> IO a
withOtlpExporter options action =
  case options of
    OtlpExporterOptions{otlpEndpoint = Left otlpGrpcEndpoint, ..} -> do
      let options' = OtlpExporterOptions{otlpEndpoint = otlpGrpcEndpoint, ..}
      withOtlpGrpcExporter options' $ action . OtlpExporterGrpc
    OtlpExporterOptions{otlpEndpoint = Right otlpHttpEndpoint, ..} -> do
      let options' = OtlpExporterOptions{otlpEndpoint = otlpHttpEndpoint, ..}
      withOtlpHttpProtobufExporter options' $ action . OtlpExporterHttpProtobuf

{- |
The options for an OTLP endpoint.
-}
type OtlpEndpoint = Either OtlpGrpcEndpoint OtlpHttpEndpoint

{- |
Parse the OTLP endpoint from a t`String` to an t`OtlpEndpoint`.
-}
parseOtlpExporterOptions :: OtlpExporterOptions String -> Either String (OtlpExporterOptions OtlpEndpoint)
parseOtlpExporterOptions OtlpExporterOptions{..} = do
  otlpEndpoint' <- parseOtlpEndpoint otlpProtocol otlpEndpoint
  let !options' = OtlpExporterOptions{otlpEndpoint = otlpEndpoint', ..}
  case otlpProtocol of
    OtlpProtocolGrpc
      | Just headers <- otlpHttpHeaders
      , not (null headers) -> do
          let showHeaders = L.intercalate "," . map (\(name, value) -> name <> "=" <> value)
          Left $ "The grpc protocol does not support additional HTTP headers, found " <> showHeaders headers
    OtlpProtocolHttpProtobuf
      | Just _sslKeyLog <- otlpGrpcSslKeyLog ->
          Left $ "The http/protobuf protocol does not support the SSL key log."
    OtlpProtocolHttpProtobuf
      | Just certificateStore <- otlpGrpcCertificateStore ->
          Left $ "The http/protobuf protocol does not support the certificate store, found " <> certificateStore
    _otherwise -> pure options'

{- |
Parse an OTLP endpoint string as an t`OtlpEndpoint` depending on the t`OtlpProtocol`.
-}
parseOtlpEndpoint :: OtlpProtocol -> String -> Either String OtlpEndpoint
parseOtlpEndpoint = go True
 where
  go :: Bool -> OtlpProtocol -> String -> Either String OtlpEndpoint
  go retry otlpProtocol url =
    case URI.parseURI url of
      Just URI.URI{..} ->
        case otlpProtocol of
          OtlpProtocolGrpc
            | uriScheme `elem` ["http:", "https:"]
            , null uriPath
            , null uriQuery
            , null uriFragment -> do
                let !host = maybe "localhost" (.uriRegName) uriAuthority
                let !port = fromMaybe 4317 $ uriPortNumber uriAuthority
                let !secure = uriScheme == "https:"
                pure $ Left OtlpGrpcEndpoint{..}
            | otherwise ->
                Left $ "The gRPC protocol only supports HTTP and HTTPS and does not support an URI path, query, or fragment, found: " <> url
          OtlpProtocolHttpProtobuf
            | uriScheme `elem` ["http:", "https:"]
            , null uriQuery
            , null uriFragment -> do
                let !host = maybe "localhost" (.uriRegName) uriAuthority
                let !port = fromMaybe 4317 $ uriPortNumber uriAuthority
                let !auth = URI.nullURIAuth{URI.uriRegName = host, URI.uriPort = ':' : show port}
                let !baseURI = URI.nullURI{URI.uriScheme = uriScheme, URI.uriAuthority = Just auth, URI.uriPath = uriPath}
                pure $ Right OtlpHttpEndpoint{baseUrl = show baseURI}
            | otherwise ->
                Left $ "The HTTP/Protobuf protocol only supports HTTP and HTTPS and does not support an URI query or fragment, found: " <> url
      Nothing
        | retry ->
            go False otlpProtocol $ "http://" <> url
        | otherwise ->
            Left $ "Could not parse url " <> url

{- |
Internal helper.

Extract a t`Word16` port number from a `URI.URIAuth`.
-}
uriPortNumber :: Maybe URI.URIAuth -> Maybe Word16
uriPortNumber = readMaybe @Word16 <=< fmap (dropColon . (.uriPort))
 where
  dropColon :: String -> String
  dropColon = \case (':' : str) -> str; str -> str

{- |
Export telemetry data to the t`OtlpExporter`.
-}
export ::
  forall serv meth.
  ( CanExportViaGrpc serv meth
  , CanExportViaHttpProtobuf serv meth
  ) =>
  -- | The HTTP/Protobuf exporter.
  OtlpExporter ->
  -- | The request message.
  MethodInput serv meth ->
  IO (MethodOutput serv meth)
export = \case
  OtlpExporterGrpc exporter -> exportGrpc @serv @meth exporter
  OtlpExporterHttpProtobuf exporter -> exportHttpProtobuf @serv @meth exporter

--------------------------------------------------------------------------------
-- OTLP gRPC Exporter
--------------------------------------------------------------------------------

{- |
An opaque OTLP gRPC exporter.
-}
newtype OtlpGrpcExporter = OtlpGrpcExporter
  { connection :: G.Connection
  }

{- |
The options for an OTLP gRPC endpoint.
-}
data OtlpGrpcEndpoint = OtlpGrpcEndpoint
  { host :: !String
  , port :: !Word16
  , secure :: !Bool
  }

type CanExportViaGrpc serv meth =
  ( G.SupportsClientRpc (Protobuf serv meth)
  , G.SupportsStreamingType (Protobuf serv meth) 'NonStreaming
  , G.RequestMetadata (Protobuf serv meth) ~ G.NoMetadata
  )

withOtlpGrpcExporter :: OtlpExporterOptions OtlpGrpcEndpoint -> (OtlpGrpcExporter -> IO a) -> IO a
withOtlpGrpcExporter OtlpExporterOptions{otlpEndpoint = OtlpGrpcEndpoint{..}, ..} action =
  G.withConnection G.def server $ \connection -> action OtlpGrpcExporter{..}
 where
  server :: G.Server
  server
    | secure = G.ServerSecure serverValidation sslKeyLog address
    | otherwise = G.ServerInsecure address

  address = G.Address host (fromIntegral port) Nothing
  sslKeyLog = fromMaybe G.SslKeyLogNone otlpGrpcSslKeyLog
  serverValidation = G.ValidateServer $ maybe G.certStoreFromSystem G.certStoreFromPath otlpGrpcCertificateStore

exportGrpc ::
  forall serv meth.
  (CanExportViaGrpc serv meth) =>
  OtlpGrpcExporter ->
  MethodInput serv meth ->
  IO (MethodOutput serv meth)
exportGrpc grpcExporter input =
  G.getProto <$> G.nonStreaming grpcExporter.connection (G.rpc @(G.Protobuf serv meth)) (G.Proto input)

--------------------------------------------------------------------------------
-- OTLP HTTP/Protobuf Exporter
--------------------------------------------------------------------------------

{- |
The options for an OTLP HTTP/Protobuf endpoint.
-}
newtype OtlpHttpEndpoint = OtlpHttpEndpoint
  { baseUrl :: String
  }
  deriving (Show)

data OtlpHttpProtobufExporter = OtlpHttpProtobufExporter
  { manager :: H.Manager
  , baseUrl :: String
  , headers :: HTTP.RequestHeaders
  }

data HttpError
  = HttpStatusError
      { statusCode :: Int
      , statusMessage :: ByteString
      , responseBody :: ByteString
      }
  | HttpDecodeError
      { errorMessage :: String
      }
  deriving (Show)

instance Exception HttpError where
  displayException :: HttpError -> String
  displayException = \case
    HttpStatusError{..} ->
      "OpenTelemetry Collector HTTP/Protobuf endpoint returned status "
        <> show statusCode
        <> " "
        <> BSC.unpack statusMessage
        <> " with body: "
        <> BSC.unpack responseBody
    HttpDecodeError{..} ->
      "Could not decode OpenTelemetry Collector HTTP/Protobuf response: "
        <> errorMessage

{- |
Internal helper.

Run an action with an t`OtlpHttpProtobufExporter`.
-}
withOtlpHttpProtobufExporter :: OtlpExporterOptions OtlpHttpEndpoint -> (OtlpHttpProtobufExporter -> IO a) -> IO a
withOtlpHttpProtobufExporter OtlpExporterOptions{otlpEndpoint = OtlpHttpEndpoint{..}, ..} action = do
  -- Create an HTTP manager.
  manager <- H.newManager H.tlsManagerSettings
  -- Create the HTTP headers.
  let headers = [(CI.mk (BSC.pack name), BSC.pack value) | (name, value) <- fromMaybe [] otlpHttpHeaders]
  -- Run the action.
  action OtlpHttpProtobufExporter{..}

class
  ( Message (MethodInput serv meth)
  , Message (MethodOutput serv meth)
  ) =>
  CanExportViaHttpProtobuf serv meth
  where
  apiPath :: String

{- |
Send a Protobuf message over an HTTP connection.
-}
exportHttpProtobuf ::
  forall serv meth.
  (CanExportViaHttpProtobuf serv meth) =>
  -- | The HTTP/Protobuf exporter.
  OtlpHttpProtobufExporter ->
  -- | The request message.
  MethodInput serv meth ->
  IO (MethodOutput serv meth)
exportHttpProtobuf OtlpHttpProtobufExporter{..} req = do
  baseRequest <- H.parseRequest (baseUrl <> apiPath @serv @meth)
  let request =
        baseRequest
          { H.method = "POST"
          , H.requestBody = H.RequestBodyBS (Proto.encodeMessage req)
          , H.checkResponse = \_ _ -> pure ()
          , H.requestHeaders =
              [ (HTTP.hContentType, "application/x-protobuf")
              , (HTTP.hAccept, "application/x-protobuf")
              ]
                <> headers
          }
  response <- H.httpLbs request manager
  let status = H.responseStatus response
  let body = BSL.toStrict (H.responseBody response)
  if HTTP.statusIsSuccessful status
    then decodeResponseBody body
    else
      throwIO
        HttpStatusError
          { statusCode = HTTP.statusCode status
          , statusMessage = HTTP.statusMessage status
          , responseBody = body
          }

{- |
Internal helper.

Decode the HTTP response body into a Protobuf message.
-}
decodeResponseBody :: (Message msg) => ByteString -> IO msg
decodeResponseBody body
  | BS.null body = pure defMessage
  | otherwise =
      case Proto.decodeMessage body of
        Left errorMessage -> throwIO HttpDecodeError{..}
        Right msg -> pure msg