packages feed

eventlog-live-0.9.0.0: src-app/GHC/Eventlog/Live/Otlp/Exporter/Core.hs

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

module GHC.Eventlog.Live.Otlp.Exporter.Core (
  -- * Exporter
  Exporter (..),
  withExporter,
  withExporters,
  export,

  -- ** Export via gRPC
  CanExportToConsole,

  -- ** Export via gRPC
  CanExportToOltpViaGrpc,

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

import Codec.Compression.GZip qualified as GZip
import Control.Exception (Exception (..), throwIO)
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.Maybe (fromMaybe)
import Data.ProtoLens.Encoding qualified as Proto
import Data.ProtoLens.Message (Message (defMessage))
import Data.ProtoLens.Service.Types (HasMethodImpl (..))
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Text.IO qualified as TIO
import GHC.Eventlog.Live.Data.Severity (Severity (..))
import GHC.Eventlog.Live.Logger (Logger, writeLog)
import GHC.Eventlog.Live.Otlp.Environment (Compression (..))
import GHC.Eventlog.Live.Otlp.Environment qualified as E (Endpoint (..), ExporterOptions (..), OtlpExporterOptions (..), PerSignal (..), Protocol (..), Timeout (..), defaultPortFor)
import GHC.IsList qualified as IsList
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.Compression 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 OpenTelemetry.Baggage qualified as Baggage

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

data Exporter
  = Exporter'Console
  | Exporter'OtlpGrpc !OtlpGrpcExporter
  | Exporter'OtlpHttpProtobuf !OtlpHttpProtobufExporter

{- |
Construct one shared t`OtlpExporter` or one t`OtlpExporter` per signal.
-}
withExporters ::
  Logger IO ->
  E.PerSignal (Maybe E.ExporterOptions) ->
  (E.PerSignal (Maybe Exporter) -> IO a) ->
  IO a
withExporters logger (E.Shared maybeOptions) action =
  withMaybeExporter logger maybeOptions $ action . E.Shared
withExporters logger E.PerSignal{..} action =
  withMaybeExporter logger forTRACES $ \exporterForTRACES ->
    withMaybeExporter logger forMETRICS $ \exporterForMETRICS ->
      withMaybeExporter logger forLOGS $ \exporterForLOGS ->
        withMaybeExporter logger forPROFILES $ \exporterForPROFILES ->
          action $ E.PerSignal exporterForTRACES exporterForMETRICS exporterForLOGS exporterForPROFILES

{- |
Construct a @Maybe t`Exporter`@ from @Maybe t`OtlpExporterOptions`@.
-}
withMaybeExporter ::
  Logger IO ->
  Maybe E.ExporterOptions ->
  (Maybe Exporter -> IO a) ->
  IO a
withMaybeExporter logger maybeOptions action =
  case maybeOptions of
    Nothing ->
      action Nothing
    Just options ->
      withExporter logger options $ action . Just

{- |
Construct an t`Exporter` from t`ExporterOptions`.
-}
withExporter ::
  Logger IO ->
  E.ExporterOptions ->
  (Exporter -> IO a) ->
  IO a
withExporter logger options action =
  case options of
    E.ExporterOptions'Console ->
      action Exporter'Console
    E.ExporterOptions'Otlp otlpExporterOptions ->
      case otlpExporterOptions.protocol of
        E.Grpc ->
          withOtlpGrpcExporter logger otlpExporterOptions $ action . Exporter'OtlpGrpc
        E.HttpProtobuf ->
          withOtlpHttpProtobufExporter logger otlpExporterOptions $ action . Exporter'OtlpHttpProtobuf

{- |
Export telemetry data to the t`OtlpExporter`.
-}
export ::
  forall serv meth.
  ( CanExportToConsole serv meth
  , CanExportToOltpViaGrpc serv meth
  , CanExportToOltpViaHttpProtobuf serv meth
  ) =>
  Logger IO ->
  -- | The HTTP/Protobuf exporter.
  Exporter ->
  -- | The request message.
  MethodInput serv meth ->
  IO (MethodOutput serv meth)
export logger = \case
  Exporter'Console -> \req -> do
    TIO.putStrLn (displayExportRequest @serv @meth req)
    pure (makeExportResponse @serv @meth req)
  Exporter'OtlpGrpc exporter ->
    exportGrpc @serv @meth logger exporter
  Exporter'OtlpHttpProtobuf exporter ->
    exportHttpProtobuf @serv @meth logger exporter

--------------------------------------------------------------------------------
-- Console Exporter
--------------------------------------------------------------------------------

class CanExportToConsole serv meth where
  {- |
  Display an export request message.
  -}
  displayExportRequest :: MethodInput serv meth -> Text
  default displayExportRequest :: (Show (MethodInput serv meth)) => MethodInput serv meth -> Text
  displayExportRequest = T.pack . show
  {-# INLINE displayExportRequest #-}

  {- |
  Construct an export response.
  -}
  makeExportResponse :: MethodInput serv meth -> MethodOutput serv meth
  default makeExportResponse :: (Message (MethodOutput serv meth)) => MethodInput serv meth -> MethodOutput serv meth
  makeExportResponse = const defMessage
  {-# INLINE makeExportResponse #-}

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

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

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

withOtlpGrpcExporter ::
  Logger IO ->
  E.OtlpExporterOptions ->
  (OtlpGrpcExporter -> IO a) ->
  IO a
withOtlpGrpcExporter logger options action = do
  writeLog logger DEBUG . T.pack $
    "OTLP gRPC Exporter - Endpoint: " <> show options.endpoint
  let !connParams =
        G.def
          { -- The compression negotiation strategy:
            G.connCompression =
              case options.maybeCompression of
                Nothing -> G.none
                Just GZip -> G.only G.gzip
          , -- The default timeout:
            G.connDefaultTimeout =
              if options.timeout.milliseconds == 0
                then Nothing
                else Just (G.Timeout G.Millisecond (G.TimeoutValue options.timeout.milliseconds))
          }
  G.withConnection connParams server $ \connection ->
    action OtlpGrpcExporter{..}
 where
  server :: G.Server
  server
    | options.endpoint.secure = G.ServerSecure serverValidation G.SslKeyLogNone address
    | otherwise = G.ServerInsecure address
   where
    port = fromIntegral $ fromMaybe (E.defaultPortFor options.protocol) options.endpoint.port
    address = G.Address options.endpoint.host port Nothing
    serverValidation = G.ValidateServer $ maybe G.certStoreFromSystem G.certStoreFromPath options.maybeCertificate

exportGrpc ::
  forall serv meth.
  (CanExportToOltpViaGrpc serv meth) =>
  Logger IO ->
  OtlpGrpcExporter ->
  MethodInput serv meth ->
  IO (MethodOutput serv meth)
exportGrpc _logger grpcExporter input =
  let callParams :: G.CallParams (G.Protobuf serv meth)
      callParams = G.def
   in G.getProto <$> G.nonStreaming grpcExporter.connection (G.rpcWith callParams) (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
  , maybeCompression :: Maybe Compression
  }

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

instance Exception HttpError where
  displayException :: HttpError -> String
  displayException = \case
    HttpStatusError{..} ->
      "OTLP HTTP/Protobuf Exporter - HTTP Response: "
        <> show statusCode
        <> " "
        <> BSC.unpack statusMessage
        <> " with body: "
        <> BSC.unpack responseBody
    HttpDecodeError{..} ->
      "OTLP HTTP/Protobuf Exporter - Malformed HTTP Response: "
        <> errorMessage

{- |
Internal helper.

Run an action with an t`OtlpHttpProtobufExporter`.
-}
withOtlpHttpProtobufExporter ::
  Logger IO ->
  E.OtlpExporterOptions ->
  (OtlpHttpProtobufExporter -> IO a) ->
  IO a
withOtlpHttpProtobufExporter logger options action = do
  writeLog logger DEBUG . T.pack $
    "OTLP HTTP/Protobuf Exporter - Endpoint: " <> show options.endpoint
  -- Create HTTP manager settings.
  let responseTimeout
        | options.timeout.microseconds == 0 =
            H.responseTimeoutNone
        | options.timeout.microseconds <= fromIntegral (maxBound @Int) =
            H.responseTimeoutMicro (fromIntegral options.timeout.microseconds)
        | otherwise =
            -- NOTE: Oh no, this truncates the response timeout to 292,271 years!
            H.responseTimeoutMicro maxBound
  -- Create an HTTP manager.
  --
  -- NOTE: newTlsManagerWith respects proxy environment variables
  manager <-
    H.newTlsManagerWith H.tlsManagerSettings{H.managerResponseTimeout = responseTimeout}
  -- Create the HTTP headers.
  writeLog logger TRACE . T.pack $
    "OTLP HTTP/Protobuf Exporter - Headers: " <> show options.maybeHeaders
  let headers =
        [ (CI.mk (Baggage.tokenValue token), TE.encodeUtf8 value)
        | (token, Baggage.Element value _properties) <-
            IsList.toList (maybe mempty Baggage.values options.maybeHeaders)
        ]
  -- Run the action.
  action
    OtlpHttpProtobufExporter
      { baseUrl = show options.endpoint
      , maybeCompression = options.maybeCompression
      , ..
      }

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

{- |
Send a Protobuf message over an HTTP connection.
-}
exportHttpProtobuf ::
  forall serv meth.
  (CanExportToOltpViaHttpProtobuf serv meth) =>
  Logger IO ->
  -- | The HTTP/Protobuf exporter.
  OtlpHttpProtobufExporter ->
  -- | The request message.
  MethodInput serv meth ->
  IO (MethodOutput serv meth)
exportHttpProtobuf logger OtlpHttpProtobufExporter{..} req = do
  baseRequest <- H.parseRequest (baseUrl <> apiPath @serv @meth)
  let (compressionHeaders, compress) = httpCompression maybeCompression
  let !requestBody = H.RequestBodyBS (compress (Proto.encodeMessage req))
  let request =
        baseRequest
          { H.method = "POST"
          , H.requestBody = requestBody
          , H.checkResponse = \_ _ -> pure ()
          , H.requestHeaders =
              [ (HTTP.hContentType, "application/x-protobuf")
              , (HTTP.hAccept, "application/x-protobuf")
              ]
                <> headers -- user-provided
                <> compressionHeaders
          }
  writeLog logger TRACE . T.pack $ "HTTP/Protobuf Exporter - HTTP Request:  " <> show request
  response <- H.httpLbs request manager
  writeLog logger TRACE . T.pack $ "HTTP/Protobuf Exporter - HTTP Response:  " <> show response
  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

{- |
Internal helper.

Determine HTTP compression headers and algorithms.
-}
httpCompression :: Maybe Compression -> ([HTTP.Header], ByteString -> ByteString)
httpCompression = \case
  Nothing -> ([], id)
  Just GZip -> ([(HTTP.hContentEncoding, "gzip")], BSL.toStrict . GZip.compress . BSL.fromStrict)