packages feed

mig-extra-0.1.1.0: src/Mig/Extra/Plugin/Trace.hs

{-| Debug utils for server. Simple logger for HTTP requests and responses
Also we can use real logging functions with ***By versions of the logger functions.
Simple variants are only for local testing. It prints to stdout
with no ordering of the concurrent prints.

It can be useful for fast setup of debug for your application. Example of the usage:

> applyPlugin (logHttp V2) server
-}
module Mig.Extra.Plugin.Trace (
  logReq,
  logResp,
  logReqBy,
  logRespBy,
  logHttp,
  logHttpBy,
  ppReq,
  Verbosity (..),
  withLogs,
  withLogsBy,
) where

import Control.Monad
import Control.Monad.IO.Class
import Data.Aeson ((.=))
import Data.Aeson qualified as Json
import Data.Aeson.Key qualified as Json
import Data.ByteString (ByteString)
import Data.ByteString.Char8 qualified as B
import Data.ByteString.Lazy.Char8 qualified as BL
import Data.CaseInsensitive (CI)
import Data.CaseInsensitive qualified as CI
import Data.Map.Strict qualified as Map
import Data.String
import Data.Text (Text)
import Data.Text.Encoding qualified as Text
import Data.Time
import Data.Yaml qualified as Yaml
import Network.HTTP.Media.RenderHeader (renderHeader)
import Network.HTTP.Types.Status (Status (..))
import System.Time.Extra

import Mig.Core

-- | Verbosity level of echo prints
data Verbosity
  = -- | prints nothing
    V0
  | -- | prints time, path query, essential headers
    V1
  | -- | prints V1 + body
    V2
  | -- | prints V2 + all headers
    V3
  deriving (Eq, Ord, Show)

ifLevel :: Verbosity -> Verbosity -> [a] -> [a]
ifLevel current level vals
  | level <= current = vals
  | otherwise = []

withLogs :: (MonadIO m) => Server m -> Server m
withLogs = applyPlugin (logHttp V2)

withLogsBy :: (MonadIO m) => (Json.Value -> m ()) -> Server m -> Server m
withLogsBy toLogItem = applyPlugin (logHttpBy toLogItem V2)

-------------------------------------------------------------------------------------
-- through

-- | Logging of requests and responses
logHttp :: (MonadIO m) => Verbosity -> Plugin m
logHttp verbosity = logResp verbosity <> logReq verbosity

-- | Logging of requests and responses with custom logger
logHttpBy :: (MonadIO m) => (Json.Value -> m ()) -> Verbosity -> Plugin m
logHttpBy printer verbosity = logRespBy printer verbosity <> logReqBy printer verbosity

-------------------------------------------------------------------------------------
-- request

-- | Logs requests
logReq :: (MonadIO m) => Verbosity -> Plugin m
logReq = logReqBy defaultPrinter

-- | Logs requests with custom logger
logReqBy :: (MonadIO m) => (Json.Value -> m ()) -> Verbosity -> Plugin m
logReqBy printer verbosity = toPlugin $ \(RawRequest req) -> prependServerAction $ do
  when (verbosity > V0) $ do
    reqTrace <- liftIO $ do
      eBody <- req.readBody
      now <- getCurrentTime
      pure $ ppReq verbosity (Just now) eBody req
    printer reqTrace

-- | Pretty prints the request
ppReq :: Verbosity -> Maybe UTCTime -> Either Text BL.ByteString -> Request -> Json.Value
ppReq verbosity now body req =
  Json.object $
    concat $
      [ ifLevel verbosity V1 $
          mconcat
            [ maybe [] (pure . ("time" .=)) now
            ,
              [ "type" .= ("http-request" :: Text)
              , "path" .= toFullPath req
              , "method" .= Text.decodeUtf8 (renderHeader req.method)
              ]
            ]
      , ifLevel
          verbosity
          V2
          [ "body" .= fromBody body
          ]
      , ["headers" .= fromHeaders req.headers]
      ]
  where
    fromHeaders headers = Json.object $ fmap go $ onVerbosity $ (Map.toList headers)
      where
        go (name, val) =
          headerName name .= Text.decodeUtf8 val

        onVerbosity
          | verbosity < V3 = filter ((\name -> name == "Accept" || name == "Content-Type") . fst)
          | otherwise = id

    fromBody :: Either Text BL.ByteString -> Json.Value
    fromBody
      | isJsonReq = either Json.String jsonBody
      | otherwise = Json.String . either id (Text.decodeUtf8 . BL.toStrict)

    isJsonReq = Map.lookup "Content-Type" req.headers == Just "application/json"

-------------------------------------------------------------------------------------
-- response

-- | Logs response
logResp :: (MonadIO m) => Verbosity -> Plugin m
logResp = logRespBy defaultPrinter

-- | Logs response with custom logger
logRespBy :: forall m. (MonadIO m) => (Json.Value -> m ()) -> Verbosity -> Plugin m
logRespBy printer verbosity = toPlugin go
  where
    go :: PluginFun m
    go = \f -> \req -> do
      (dur, resp) <- duration (f req)
      when (verbosity > V0) $ do
        now <- liftIO getCurrentTime
        mapM_ (printer . ppResp verbosity now dur req) resp
      pure resp

-- | Pretty prints the response
ppResp :: Verbosity -> UTCTime -> Seconds -> Request -> Response -> Json.Value
ppResp verbosity now dur req resp =
  Json.object $
    concat
      [ ifLevel
          verbosity
          V1
          [ "time" .= now
          , "duration" .= dur
          , "type" .= ("http-response" :: Text)
          , "path" .= toFullPath req
          , "status" .= resp.status.statusCode
          , "method" .= Text.decodeUtf8 (renderHeader req.method)
          ]
      , ifLevel
          verbosity
          V2
          ["body" .= fromBody resp.body]
      , ["headers" .= fromHeaders resp.headers]
      ]
  where
    fromHeaders headers = Json.object $ fmap go $ headers
      where
        go (name, val) = headerName name .= Text.decodeUtf8 val

    fromBody = \case
      RawResp mediaType bs | mediaType == "application/json" -> jsonBody bs
      RawResp _ bs -> Json.String $ Text.decodeUtf8 (BL.toStrict bs)
      FileResp file -> Json.object ["file" .= file]
      StreamResp -> Json.object ["stream" .= ()]

-------------------------------------------------------------------------------------
-- utils

-- | Default printer
defaultPrinter :: (MonadIO m) => Json.Value -> m ()
defaultPrinter =
  liftIO . B.putStrLn . Yaml.encode . addLogPrefix

addLogPrefix :: Json.Value -> Json.Value
addLogPrefix val = Json.object ["log" .= val]

headerName :: CI ByteString -> Json.Key
headerName name = Json.fromText (Text.decodeUtf8 $ CI.foldedCase name)

jsonBody :: BL.ByteString -> Json.Value
jsonBody =
  either fromString id . Json.eitherDecode