packages feed

log-elasticsearch-0.13.0.2: src/Log/Backend/ElasticSearch/Internal.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE DeriveGeneric #-}
module Log.Backend.ElasticSearch.Internal
  ( ElasticSearchConfig(..)
  , defaultElasticSearchConfig
  -- * ES version
  , EsVersion(..)
  , parseEsVersion
  , esV5, esV7
  -- * ES commands
  , serverInfo
  , indexExists
  , createIndexWithMapping
  , bulkIndex
  , refreshIndex
  -- * ES communication details
  , EsEnv(..)
  , mkEsEnv
  , dispatch
  , decodeReply
  , isSuccess
  ) where

import Control.Exception
import Control.Monad
import Data.Aeson
import Data.Ix (inRange)
import Data.Maybe
import GHC.Generics (Generic)
import Network.HTTP.Client
import Network.HTTP.Types
#if OPENSSL
import Network.HTTP.Client.OpenSSL (newOpenSSLManager, withOpenSSL)
#else
import Network.HTTP.Client.TLS (tlsManagerSettings)
#endif
import qualified Data.ByteString.Builder as BSB
import qualified Data.ByteString.Lazy.Char8 as BSL
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Data.Vector as V

import qualified Log.Internal.Aeson.Compat as AC

-- | Configuration for the Elasticsearch 'Logger'. See
-- <https://www.elastic.co/guide/en/elasticsearch/reference/current/glossary.html>
-- for the explanation of terms.
data ElasticSearchConfig = ElasticSearchConfig
  { esServer        :: !T.Text -- ^ Elasticsearch server address.
  , esIndex         :: !T.Text -- ^ Elasticsearch index name.
  , esShardCount    :: !Int
    -- ^ Elasticsearch shard count for the named index.
    --
    -- @since 0.10.0.0
  , esReplicaCount  :: !Int
    -- ^ Elasticsearch replica count for the named index.
    --
    -- @since 0.10.0.0
  , esMapping       :: !T.Text
    -- ^ Elasticsearch mapping name (unused with ES >= 7.0.0)
  , esLogin         :: Maybe (T.Text, T.Text)
    -- ^ Elasticsearch basic authentication username and password.
  , esLoginInsecure :: !Bool
    -- ^ Allow basic authentication over non-TLS connections.
  } deriving (Eq, Show, Generic)

-- | Sensible defaults for 'ElasticSearchConfig'.
defaultElasticSearchConfig :: ElasticSearchConfig
defaultElasticSearchConfig = ElasticSearchConfig
  { esServer        = "http://localhost:9200"
  , esIndex         = "logs"
  , esShardCount    = 4
  , esReplicaCount  = 1
  , esMapping       = "log"
  , esLogin         = Nothing
  , esLoginInsecure = False
  }

----------------------------------------
-- ES communication

-- Most of the below code is taken from the bloodhound library
-- (https://github.com/bitemyapp/bloodhound).

data EsVersion = EsVersion !Int !Int !Int
  deriving (Eq, Ord)

parseEsVersion :: Value -> Maybe EsVersion
parseEsVersion js = do
  Object props <- pure js
  Object version <- "version" `AC.lookup` props
  case "distribution" `AC.lookup` version of
    Just "opensearch" -> do
      -- OpenSearch is compatible (so far) with esV7 mappings.
      pure esV7
    _ -> do
      String number <- "number" `AC.lookup` version
      [v1, v2, v3] <- mapM (maybeRead . T.unpack) $ T.splitOn "." number
      pure $ EsVersion v1 v2 v3
  where
    maybeRead s = do
      [(v, "")] <- pure $ reads s
      pure v

-- | Minimum version with split 'string' type.
esV5 :: EsVersion
esV5 = EsVersion 5 0 0

-- | Minimum version without mapping types.
esV7 :: EsVersion
esV7 = EsVersion 7 0 0

----------------------------------------

-- | Check the ElasticSearch server for info. Result can be fed to
-- 'parseEsVersion' to determine version of the server.
serverInfo :: EsEnv -> IO (Either HttpException (Response Value))
serverInfo env = try $ dispatch env methodGet [] Nothing

-- | Check that given index exists.
indexExists :: EsEnv -> T.Text -> IO Bool
indexExists env index =
  isSuccess <$> dispatch env methodHead [index] Nothing

-- | Create an index with given mapping.
createIndexWithMapping
  :: EsVersion
  -> EsEnv
  -> ElasticSearchConfig
  -> T.Text
  -> IO (Response Value)
createIndexWithMapping version env ElasticSearchConfig{..} index = do
  dispatch env methodPut [index] . Just . encode $ object
    [ "settings" .= object
      [ "number_of_shards" .= esShardCount
      , "number_of_replicas" .= esReplicaCount
      ]
    , "mappings" .= if version >= esV7
                    then logsMapping
                    else object [ AC.fromText esMapping .= logsMapping ]
    ]
  where
    logsMapping = object
      [ "properties" .= object
        [ "time" .= object
          [ "type"   .= timeTy
          , "format" .= ("date_time"::T.Text)
          ]
        , "domain" .= object
          [ "type" .= textTy
          ]
        , "level" .= object
          [ "type" .= textTy
          ]
        , "component" .= object
          [ "type" .= textTy
          ]
        , "message" .= object
          [ "type" .= textTy
          ]
        ]
      ]
      where
        timeTy :: T.Text
        timeTy = if version >= esV7
                 then "date_nanos"
                 else "date"

        textTy :: T.Text
        textTy = if version >= esV5
                 then "text"
                 else "string"

-- Index multiple log messages.
bulkIndex
  :: EsVersion
  -> EsEnv
  -> ElasticSearchConfig
  -> T.Text
  -> V.Vector Object
  -> IO (Response Value)
bulkIndex version env conf index objs = do
  dispatch env methodPost route . Just . BSB.toLazyByteString $ foldMap ixOp objs
  where
    route = if version >= esV7
            then [index, "_bulk"]
            else [index, esMapping conf, "_bulk"]

    ixOp obj = ixCmd
            <> BSB.char8 '\n'
            <> BSB.lazyByteString (encode $ Object obj)
            <> BSB.char8 '\n'
      where
        ixCmd = BSB.lazyByteString . encode $ object
          [ "index" .= object []
          ]

-- Refresh given index.
refreshIndex :: EsEnv -> T.Text -> IO ()
refreshIndex env index =
  void $ dispatch env methodPost [index, "_refresh"] Nothing

----------------------------------------

data EsEnv = EsEnv
  { envServer      :: !T.Text
  , envManager     :: !Manager
  , envRequestHook :: !(Request -> Request)
  }

mkEsEnv :: ElasticSearchConfig -> IO EsEnv
mkEsEnv ElasticSearchConfig{..} = do
#if OPENSSL
  envManager <- withOpenSSL newOpenSSLManager
#else
  envManager <- newManager tlsManagerSettings
#endif
  let envServer = esServer
      envRequestHook = maybe id mkAuthHook esLogin
  pure EsEnv{..}
  where
    mkAuthHook (u, p) = applyBasicAuth (T.encodeUtf8 u) (T.encodeUtf8 p)

----------------------------------------

dispatch :: EsEnv
         -> Method
         -> [T.Text]
         -> Maybe BSL.ByteString
         -> IO (Response Value)
dispatch EsEnv{..} dMethod url body = do
  initReq <- parseRequest $ T.unpack $ T.intercalate "/" $ envServer : url
  let req = envRequestHook . setRequestIgnoreStatus $ initReq
        { method = dMethod
        , requestBody = RequestBodyLBS $ fromMaybe BSL.empty body
        , requestHeaders = ("Content-Type", "application/json") : requestHeaders initReq
        }
  fmap decodeReply <$> httpLbs req envManager

decodeReply :: BSL.ByteString -> Value
decodeReply bs = case eitherDecode' bs of
  Right js  -> js
  Left  err -> object ["decoding_error" .= err]

isSuccess :: Response a -> Bool
isSuccess = statusCheck (inRange (200, 299))
  where
    statusCheck :: (Int -> Bool) -> Response a -> Bool
    statusCheck p = p . statusCode . responseStatus