graphql-engine/server/src-lib/Hasura/Logging.hs
Brandon Simmons 7b3ce7d927 server: don't use 'auto-update' for log timestamp cache/refresh
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/10662
GitOrigin-RevId: abefec7649098cfb6e4cc906210bc709c25301b0
2024-02-06 22:07:58 +00:00

562 lines
20 KiB
Haskell

{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE ViewPatterns #-}
module Hasura.Logging
( LoggerSettings (..),
defaultLoggerSettings,
EngineLogType (..),
Hasura,
InternalLogTypes (..),
EngineLog (..),
FormattedTime, -- N.B. opaque
toPOSIX_ns,
userAllowedLogTypes,
ToEngineLog (..),
debugT,
debugBS,
debugLBS,
UnstructuredLog (..),
Logger (.., Logger, unLogger),
LogLevel (..),
prettyLogLevel,
UnhandledInternalErrorLog (..),
mkLogger,
nullLogger,
-- ** LoggerCtx
LoggerCtx,
getLoggerSet,
getTimeGetter,
getLogLevel,
getEnabledLogTypes,
getLogsExporter,
mkLoggerCtx,
mkLoggerCtxOTLP,
cleanLoggerCtx,
-- ** etc
eventTriggerLogType,
eventTriggerProcessLogType,
scheduledTriggerLogType,
scheduledTriggerProcessLogType,
cronEventGeneratorProcessType,
sourceCatalogMigrationLogType,
EnabledLogTypes (..),
defaultEnabledEngineLogTypes,
isEngineLogTypeEnabled,
readLogTypes,
getFormattedTime,
-- * Debounced stats logger
createStatsLogger,
closeStatsLogger,
logStats,
-- * Other internal logs
StoredIntrospectionLog (..),
StoredIntrospectionStorageLog (..),
)
where
import Control.Exception (ErrorCall (ErrorCallWithLocation), catch)
import Control.FoldDebounce qualified as FDebounce
import Control.Monad.Trans.Control
import Control.Monad.Trans.Managed (ManagedT (..), allocate)
import Data.Aeson qualified as J
import Data.Aeson.Types qualified as J
import Data.ByteString qualified as B
import Data.ByteString.Lazy qualified as BL
import Data.ByteString.Lazy.Char8 qualified as BLC
import Data.HashSet qualified as Set
import Data.IORef
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.SerializableBlob qualified as SB
import Data.String (fromString)
import Data.Text qualified as T
import Data.Time.Clock qualified as Time
import Data.Time.Clock.POSIX qualified as Time
import Data.Time.Format qualified as Format
import Data.Time.LocalTime qualified as Time
import Hasura.Base.Error (QErr)
import Hasura.CachedTime
import Hasura.Prelude
import Hasura.Tracing.Class qualified as Tracing
import Hasura.Tracing.Context
import Hasura.Tracing.TraceId
import System.Log.FastLogger qualified as FL
import Witch qualified
-- | A zoned timestamp with defined serialized format (via the ToJSON instance)
--
-- Internals not exported. Construct with 'getFormattedTime'
data FormattedTime = FormattedTime Time.UTCTime Time.TimeZone
deriving (Show, Eq)
instance J.ToJSON FormattedTime where
toJSON (FormattedTime t tz) = J.toJSON $ T.pack $ formatTime $ Time.utcToZonedTime tz t
where
formatTime = Format.formatTime Format.defaultTimeLocale format
format = "%FT%H:%M:%S%3Q%z"
-- format = Format.iso8601DateFormat (Just "%H:%M:%S")
-- | Timestamp as uniz epoch time, in nanoseconds.
toPOSIX_ns :: FormattedTime -> Word64
toPOSIX_ns (FormattedTime t _) = floor . (* 1e9) . Time.utcTimeToPOSIXSeconds $ t
-- | Typeclass representing any type which can be parsed into a list of enabled log types, and has a @Set@
-- of default enabled log types, and can find out if a log type is enabled
class (Eq (EngineLogType impl), Hashable (EngineLogType impl)) => EnabledLogTypes impl where
parseEnabledLogTypes :: String -> Either String [EngineLogType impl]
defaultEnabledLogTypes :: Set.HashSet (EngineLogType impl)
isLogTypeEnabled :: Set.HashSet (EngineLogType impl) -> EngineLogType impl -> Bool
-- | A family of EngineLogType types
data family EngineLogType impl
data Hasura
-- log types emitted from the OSS core.
-- NOTE: however these are also emitted by e.g. graphql-engine-pro (in addition
-- to other log types)
data instance EngineLogType Hasura
= ELTHttpLog
| ELTWebsocketLog
| ELTWebhookLog
| ELTQueryLog
| ELTExecutionLog
| ELTStartup
| ELTLivequeryPollerLog
| ELTActionHandler
| ELTDataConnectorLog
| ELTJwkRefreshLog
| ELTValidateInputLog
| -- internal log types
ELTInternal !InternalLogTypes
deriving (Show, Eq, Generic)
instance Hashable (EngineLogType Hasura)
instance Witch.From (EngineLogType Hasura) Text where
from = \case
ELTHttpLog -> "http-log"
ELTWebsocketLog -> "websocket-log"
ELTWebhookLog -> "webhook-log"
ELTQueryLog -> "query-log"
ELTExecutionLog -> "execution-log"
ELTStartup -> "startup"
ELTLivequeryPollerLog -> "livequery-poller-log"
ELTActionHandler -> "action-handler-log"
ELTDataConnectorLog -> "data-connector-log"
ELTJwkRefreshLog -> "jwk-refresh-log"
ELTValidateInputLog -> "validate-insert-input-log"
ELTInternal t -> Witch.from t
instance J.ToJSON (EngineLogType Hasura) where
toJSON = J.String . Witch.into @Text
instance J.FromJSON (EngineLogType Hasura) where
parseJSON = J.withText "log-type" $ \s ->
let logTypeText = T.toLower $ T.strip s
logTypeMaybe = Map.lookup logTypeText allowedLogTypeMapping
in logTypeMaybe `onNothing` failure
where
allowedLogTypeMapping :: Map Text (EngineLogType Hasura)
allowedLogTypeMapping =
Map.fromList $ (\lt -> (Witch.into @Text lt, lt)) <$> userAllowedLogTypes
failure :: J.Parser (EngineLogType Hasura)
failure =
fail $ "Valid list of comma-separated log types: " <> BLC.unpack (J.encode userAllowedLogTypes)
data InternalLogTypes
= -- | mostly for debug logs - see @debugT@, @debugBS@ and @debugLBS@ functions
ILTUnstructured
| ILTUnhandledInternalError
| ILTEventTrigger
| ILTEventTriggerProcess
| ILTScheduledTrigger
| ILTScheduledTriggerProcess
| ILTCronEventGeneratorProcess
| -- | internal logs for the websocket server
ILTWsServer
| ILTPgClient
| -- | log type for logging metadata related actions; currently used in logging inconsistent metadata
ILTMetadata
| ILTTelemetry
| ILTSchemaSync
| ILTSourceCatalogMigration
| ILTStoredIntrospection
| ILTStoredIntrospectionStorage
| ILTModelInfo
deriving (Show, Eq, Generic)
instance Hashable InternalLogTypes
instance Witch.From InternalLogTypes Text where
from = \case
ILTUnstructured -> "unstructured"
ILTUnhandledInternalError -> "unhandled-internal-error"
ILTEventTrigger -> "event-trigger"
ILTEventTriggerProcess -> "event-trigger-process"
ILTScheduledTrigger -> "scheduled-trigger"
ILTScheduledTriggerProcess -> "scheduled-trigger-process"
ILTCronEventGeneratorProcess -> "cron-event-generator-process"
ILTWsServer -> "ws-server"
ILTPgClient -> "pg-client"
ILTMetadata -> "metadata"
ILTTelemetry -> "telemetry-log"
ILTSchemaSync -> "schema-sync"
ILTSourceCatalogMigration -> "source-catalog-migration"
ILTStoredIntrospection -> "stored-introspection"
ILTStoredIntrospectionStorage -> "stored-introspection-storage"
ILTModelInfo -> "model-info"
instance J.ToJSON InternalLogTypes where
toJSON = J.String . Witch.into @Text
-- the default enabled log-types
defaultEnabledEngineLogTypes :: Set.HashSet (EngineLogType Hasura)
defaultEnabledEngineLogTypes =
Set.fromList [ELTStartup, ELTHttpLog, ELTWebhookLog, ELTWebsocketLog, ELTJwkRefreshLog]
isEngineLogTypeEnabled :: Set.HashSet (EngineLogType Hasura) -> EngineLogType Hasura -> Bool
isEngineLogTypeEnabled enabledTypes logTy = case logTy of
ELTInternal _ -> True
_ -> logTy `Set.member` enabledTypes
readLogTypes :: String -> Either String [EngineLogType Hasura]
readLogTypes = mapM (J.eitherDecodeStrict' . quote . txtToBs) . T.splitOn "," . T.pack
where
quote x = "\"" <> x <> "\""
instance EnabledLogTypes Hasura where
parseEnabledLogTypes = readLogTypes
defaultEnabledLogTypes = defaultEnabledEngineLogTypes
isLogTypeEnabled = isEngineLogTypeEnabled
-- log types that can be set by the user
userAllowedLogTypes :: [EngineLogType Hasura]
userAllowedLogTypes =
[ ELTStartup,
ELTHttpLog,
ELTWebhookLog,
ELTWebsocketLog,
ELTQueryLog,
ELTExecutionLog,
ELTLivequeryPollerLog,
ELTActionHandler,
ELTDataConnectorLog,
ELTJwkRefreshLog,
ELTValidateInputLog
]
data LogLevel
= LevelDebug
| LevelInfo
| LevelWarn
| LevelError
| LevelOther Text
deriving (Show, Eq, Ord)
instance J.ToJSON LogLevel where
toJSON = J.toJSON . prettyLogLevel
-- | Human-readable LogLevel, as serialized for end-users
prettyLogLevel :: LogLevel -> Text
prettyLogLevel = \case
LevelDebug -> "debug"
LevelInfo -> "info"
LevelWarn -> "warn"
LevelError -> "error"
LevelOther t -> t
-- | This is the top-level log type emitted for OSS and on-prem enterprise. It
-- is built from the output of 'toEngineLog'
data EngineLog impl = EngineLog
{ _elTimestamp :: !FormattedTime,
_elLevel :: !LogLevel,
_elType :: !(EngineLogType impl),
_elDetail :: !J.Value,
-- | The trace context in which this log message was emitted, if any. See 'unLoggerTracing'.
_elTraceId :: !(Maybe TraceId),
-- | The span context in which this log message was emitted, if any. See 'unLoggerTracing'.
_elSpanId :: !(Maybe SpanId)
}
deriving stock (Generic)
deriving instance (Show (EngineLogType impl)) => Show (EngineLog impl)
deriving instance (Eq (EngineLogType impl)) => Eq (EngineLog impl)
instance (J.ToJSON (EngineLogType impl)) => J.ToJSON (EngineLog impl) where
toJSON = J.genericToJSON hasuraJSON {J.omitNothingFields = True}
-- | Typeclass representing any data type that can be converted to @EngineLog@ for the purpose of
-- logging
class (EnabledLogTypes impl) => ToEngineLog a impl where
toEngineLog :: a -> (LogLevel, EngineLogType impl, J.Value)
data UnstructuredLog = UnstructuredLog {_ulLevel :: !LogLevel, _ulPayload :: !SB.SerializableBlob}
deriving (Show)
debugT :: Text -> UnstructuredLog
debugT = UnstructuredLog LevelDebug . SB.fromText
debugBS :: B.ByteString -> UnstructuredLog
debugBS = UnstructuredLog LevelDebug . SB.fromBS
debugLBS :: BL.ByteString -> UnstructuredLog
debugLBS = UnstructuredLog LevelDebug . SB.fromLBS
instance ToEngineLog UnstructuredLog Hasura where
toEngineLog (UnstructuredLog level t) =
(level, ELTInternal ILTUnstructured, J.toJSON t)
-- | Abstract. Constructed with 'mkLoggerCtx'.
data LoggerCtx impl = LoggerCtx
{ _lcLoggerSet :: !FL.LoggerSet,
_lcLogLevel :: !LogLevel,
_lcTimeGetter :: !(IO FormattedTime),
_lcEnabledLogTypes :: !(Set.HashSet (EngineLogType impl)),
-- | @LogsExporter@ or a noop. Wrapped in readIORef to work around cycle at
-- callsite of @runOtlpLogsExporter@
_lcLogsExporter :: !(IO (EngineLog impl -> IO ()))
}
getLoggerSet :: LoggerCtx impl -> FL.LoggerSet
getLoggerSet = _lcLoggerSet
getLogLevel :: LoggerCtx impl -> LogLevel
getLogLevel = _lcLogLevel
getTimeGetter :: LoggerCtx impl -> IO FormattedTime
getTimeGetter = _lcTimeGetter
getEnabledLogTypes :: LoggerCtx impl -> Set.HashSet (EngineLogType impl)
getEnabledLogTypes = _lcEnabledLogTypes
getLogsExporter :: LoggerCtx impl -> IO (EngineLog impl -> IO ())
getLogsExporter = _lcLogsExporter
-- * Unhandled Internal Errors
-- | We expect situations where there are code paths that should not occur and we throw
-- an 'error' on this code paths. If our assumptions are incorrect and infact
-- these errors do occur, we want to log them.
newtype UnhandledInternalErrorLog = UnhandledInternalErrorLog ErrorCall
instance ToEngineLog UnhandledInternalErrorLog Hasura where
toEngineLog (UnhandledInternalErrorLog (ErrorCallWithLocation err loc)) =
( LevelError,
ELTInternal ILTUnhandledInternalError,
J.object [("error", fromString err), ("location", fromString loc)]
)
-- * LoggerSettings
data LoggerSettings = LoggerSettings
{ -- | should current time be cached (refreshed every sec)? For performance
-- impact, see benchmarks in: https://github.com/hasura/graphql-engine-mono/pull/10631
_lsCachedTimestamp :: !Bool,
_lsTimeZone :: !(Maybe Time.TimeZone),
_lsLevel :: !LogLevel
}
deriving (Show, Eq)
defaultLoggerSettings :: Bool -> LogLevel -> LoggerSettings
defaultLoggerSettings isCached =
LoggerSettings isCached Nothing
-- | Get the current time, formatted with the current or specified timezone
getFormattedTime :: Maybe Time.TimeZone -> IO FormattedTime
getFormattedTime tzM = do
tz <- onNothing tzM Time.getCurrentTimeZone
t <- Time.getCurrentTime
return $ FormattedTime t tz
-- | Get the current time, formatted with the current or specified timezone
getCachedFormattedTime :: Maybe Time.TimeZone -> IO FormattedTime
getCachedFormattedTime tzM = do
(t, tz, _) <- readIORef cachedRecentFormattedTimeAndZone
pure $ maybe (FormattedTime t tz) (FormattedTime t) tzM
-- | Creates a new 'LoggerCtx', optionally fanning out to an OTLP endpoint
-- (while enabled) as well.
--
-- The underlying 'LoggerSet' is bound to the 'ManagedT' context: when it exits,
-- the log will be flushed and cleared regardless of whether it was exited
-- properly or not ('ManagedT' uses 'bracket' underneath). This guarantees that
-- the logs will always be flushed, even in case of error, avoiding a repeat of
-- https://github.com/hasura/graphql-engine/issues/4772.
mkLoggerCtxOTLP ::
(MonadIO io, MonadBaseControl IO io) =>
-- | @LogsExporter@ or a noop. Wrapped in readIORef to work around cycle at
-- callsite of @runOtlpLogsExporter@
IO (EngineLog impl -> IO ()) ->
LoggerSettings ->
Set.HashSet (EngineLogType impl) ->
ManagedT io (LoggerCtx impl)
mkLoggerCtxOTLP logsExporter (LoggerSettings shouldCacheTime tzM logLevel) enabledLogs = do
loggerSet <- allocate acquire release
pure $ LoggerCtx loggerSet logLevel (timeGetter tzM) enabledLogs logsExporter
where
acquire = liftIO do
FL.newStdoutLoggerSet FL.defaultBufSize
release loggerSet = liftIO do
FL.flushLogStr loggerSet
FL.rmLoggerSet loggerSet
-- use either a slower time lookup per log line, or quick reference to not
-- very granular current-ish timestamp
timeGetter
| shouldCacheTime = getCachedFormattedTime
| otherwise = getFormattedTime
-- | 'mkLoggerCtxOTLP' but with no otlp log shipping, for compatibility
mkLoggerCtx ::
(MonadIO io, MonadBaseControl IO io) =>
LoggerSettings ->
Set.HashSet (EngineLogType impl) ->
ManagedT io (LoggerCtx impl)
mkLoggerCtx = mkLoggerCtxOTLP (pure (\_ -> pure ()))
cleanLoggerCtx :: LoggerCtx a -> IO ()
cleanLoggerCtx =
FL.rmLoggerSet . _lcLoggerSet
-- | A callback capable of actually emitting a log line (e.g. to stdout). If
-- not in a 'MonadTrace' context you can make use of the old API via 'Logger'
-- and 'unLogger'.
newtype Logger impl = LoggerTracing {unLoggerTracing :: forall a m. (ToEngineLog a impl, Tracing.MonadTraceContext m, MonadIO m) => a -> m ()}
-- | This is kept for compatibility with the old interface, which didn't
-- require a 'MonadTraceContext' environment
pattern Logger :: forall impl. (forall a m. (ToEngineLog a impl, MonadIO m) => a -> m ()) -> Logger impl
pattern Logger {unLogger} <- (newToOrig -> unLogger)
where
Logger f = LoggerTracing f
{-# COMPLETE Logger :: Logger #-}
-- Internal. To get 'pattern Logger' to typecheck
newToOrig :: Logger impl -> (forall a m. (ToEngineLog a impl, MonadIO m) => a -> m ())
newToOrig (LoggerTracing f) = fmap Tracing.runNoMonadTraceContext f
mkLogger :: (J.ToJSON (EngineLogType impl)) => LoggerCtx impl -> Logger impl
mkLogger (LoggerCtx loggerSet serverLogLevel timeGetter enabledLogTypes logsExporter) = LoggerTracing $ \l -> do
-- NOTE: This has us logging a trace and span id even in the OSS server,
-- where tracing isn't actually supported. We decided this was fine, and
-- actually might end up being useful as a way for OSS users to correlate
-- logs that are part of the same operation
cxt <- Tracing.currentContext
let mbCurrentSpan = tcCurrentSpan <$> cxt
mbCurrentTrace = tcCurrentTrace <$> cxt
localTime <- liftIO timeGetter
let (logLevel, logTy, logDet) = toEngineLog l
when (logLevel >= serverLogLevel && isLogTypeEnabled enabledLogTypes logTy) $ liftIO do
let logLine = EngineLog localTime logLevel logTy logDet mbCurrentTrace mbCurrentSpan
FL.pushLogStrLn loggerSet $ FL.toLogStr (J.encode logLine)
logsExporter >>= \f -> f logLine
nullLogger :: Logger Hasura
nullLogger = Logger \_ -> pure ()
eventTriggerLogType :: EngineLogType Hasura
eventTriggerLogType = ELTInternal ILTEventTrigger
eventTriggerProcessLogType :: EngineLogType Hasura
eventTriggerProcessLogType = ELTInternal ILTEventTriggerProcess
scheduledTriggerLogType :: EngineLogType Hasura
scheduledTriggerLogType = ELTInternal ILTScheduledTrigger
scheduledTriggerProcessLogType :: EngineLogType Hasura
scheduledTriggerProcessLogType = ELTInternal ILTScheduledTriggerProcess
cronEventGeneratorProcessType :: EngineLogType Hasura
cronEventGeneratorProcessType = ELTInternal ILTCronEventGeneratorProcess
sourceCatalogMigrationLogType :: EngineLogType Hasura
sourceCatalogMigrationLogType = ELTInternal ILTSourceCatalogMigration
-- | Emit when stored introspection is used
data StoredIntrospectionLog = StoredIntrospectionLog
{ silMessage :: Text,
-- | upstream data source errors
silSourceError :: QErr
}
deriving stock (Generic)
instance J.ToJSON StoredIntrospectionLog where
toJSON = J.genericToJSON hasuraJSON
instance ToEngineLog StoredIntrospectionLog Hasura where
toEngineLog siLog =
(LevelInfo, ELTInternal ILTStoredIntrospection, J.toJSON siLog)
-- | Logs related to errors while interacting with the stored introspection
-- storage
data StoredIntrospectionStorageLog = StoredIntrospectionStorageLog
{ sislMessage :: Text,
sislError :: QErr
}
deriving stock (Generic)
instance J.ToJSON StoredIntrospectionStorageLog where
toJSON = J.genericToJSON hasuraJSON
instance ToEngineLog StoredIntrospectionStorageLog Hasura where
toEngineLog sisLog =
(LevelInfo, ELTInternal ILTStoredIntrospectionStorage, J.toJSON sisLog)
-- | A logger useful for accumulating and logging stats, in tight polling loops. It also
-- debounces to not flood with excessive logs. Use @'logStats' to record statistics for logging.
createStatsLogger ::
forall m stats impl.
( MonadIO m,
ToEngineLog stats impl,
Monoid stats
) =>
Logger impl ->
m (FDebounce.Trigger stats stats)
createStatsLogger hasuraLogger =
liftIO $ FDebounce.new debounceArgs debounceOpts
where
logDelay :: Int
logDelay =
-- Accumulate stats occurred within 10 minutes and log once.
10 * 60 * 1000_000 -- 10 minutes
debounceArgs :: FDebounce.Args stats stats
debounceArgs =
FDebounce.Args
{ FDebounce.cb = unLogger hasuraLogger, -- Log using the Hasura logger
FDebounce.fold = (<>),
FDebounce.init = mempty
}
debounceOpts :: FDebounce.Opts stats stats
debounceOpts = FDebounce.def {FDebounce.delay = logDelay}
-- Orphan instance. Required for @'closeStatsLogger'.
instance (EnabledLogTypes impl) => ToEngineLog (FDebounce.OpException, EngineLogType impl) impl where
toEngineLog (opException, logType) =
let errorMessage :: Text
errorMessage = case opException of
FDebounce.AlreadyClosedException -> "already closed"
FDebounce.UnexpectedClosedException _someException -> "closed unexpectedly"
in (LevelWarn, logType, J.object ["message" J..= ("cannot close fetched events stats logger: " <> errorMessage)])
-- | Safely close the statistics logger. When occurred, exception is logged.
closeStatsLogger :: (MonadIO m, EnabledLogTypes impl) => EngineLogType impl -> Logger impl -> FDebounce.Trigger stats stats -> m ()
closeStatsLogger logType (Logger hasuraLogger) debounceLogger =
liftIO $ catch (FDebounce.close debounceLogger) $ \(e :: FDebounce.OpException) -> hasuraLogger (e, logType)
-- | This won't log the given stats immediately.
-- The stats are accumulated over the specific timeframe and logged only once.
-- See @'createStatsLogger' for more details.
logStats :: (MonadIO m) => FDebounce.Trigger stats stats -> stats -> m ()
logStats debounceTrigger = liftIO . FDebounce.send debounceTrigger