{-# LANGUAGE DeriveFunctor #-}
{-# LANGUAGE DeriveTraversable #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE StandaloneDeriving #-}
-- | This module is dedicated to logging information in production, to help
-- understand what the application is doing when something goes wrong. This sets
-- it apart from the @Debug@ module which provide helpers for debugging problems
-- in development.
--
-- This module does not have an Elm counterpart.
module Log
( -- * Logging
debug,
info,
warn,
error,
withContext,
context,
-- * Secrets
Secret,
mkSecret,
unSecret,
-- * For use in observability modules
Context (..),
LogContexts (..),
)
where
import Data.Aeson ((.=))
import qualified Data.Aeson as Aeson
import qualified GHC.Stack as Stack
import NriPrelude
import qualified Platform
import qualified Platform.Internal as Internal
import qualified Task
import qualified Text.Show
import qualified Prelude
-- | A log message that is probably only useful in development, or when we're
-- really confused about something and need ALL THE CONTEXT.
--
-- In addition to a log message you can pass additional key-value pairs with
-- information that might be relevant for debugging.
--
-- > debug "Computation partially succeeded" [context "answer" 2]
debug :: Stack.HasCallStack => Text -> [Context] -> Task e ()
debug message contexts =
Stack.withFrozenCallStack
log
message
ReportAsSucceeded
(Context "level" Debug : contexts)
-- | A log message useful for when things have gone off the rails.
-- We should have a ton of messages at this level.
-- It should help us out when we're dealing with something hard.
--
-- In addition to a log message you can pass additional key-value pairs with
-- information that might be relevant for debugging.
--
-- > info "I added 1 and 1" [context "answer" 2]
info :: Stack.HasCallStack => Text -> [Context] -> Task e ()
info message contexts =
Stack.withFrozenCallStack
log
message
ReportAsSucceeded
(Context "level" Info : contexts)
-- | A log message when something went wrong, but it did not go wrong in a way
-- to totally break the thing we're doing. These should be triaged and fixed
-- soon, but aren't show-stoppers.
--
-- In addition to a log message you can pass additional key-value pairs with
-- information that might be relevant for debugging.
--
-- > warn "This field was sent, but we're gonna deprecate it!" []
warn :: Stack.HasCallStack => Text -> [Context] -> Task e ()
warn message contexts =
Stack.withFrozenCallStack
log
message
ReportAsFailed
(Context "level" Warn : contexts)
-- | A log message when we can't continue with what we were trying to do
-- because of a problem.
--
-- In addition to a log message you can pass additional key-value pairs with
-- information that might be relevant for debugging.
--
-- > error "The user tried to request this thing, but they aren't allowed!" []
error :: Stack.HasCallStack => Text -> [Context] -> Task e ()
error message contexts =
Stack.withFrozenCallStack
log
message
ReportAsFailed
(Context "level" Error : contexts)
-- | Mark a block of code as a logical unit by giving it a name. This name will
-- be used in logs and monitoring dashboards, so use this function to help
-- debug production problems.
--
-- In addition to a name you can pass this function a list of context. A
-- context is a key-value pair you want to attach to all logs made inside of
-- the block of code wrapped.
--
-- Example usage:
--
-- > withContext "play-music" [context "artist" "The Beatles"] <| do
-- > -- your code here!
--
-- Additionally, this function adds an entry to our homemade stack trace for if something errors.
-- Why not use the built-in stack trace? Well, the built-in stack trace only records a frame if you
-- add @Stack.HasCallStack =>@ to the function, so if we want a full stack trace, we need to add
-- that to literally all functions. Instead of doing that, we will use @withContext@ to collect
-- the stack trace, since it is used fairly often already. It will not be complete either, but
-- it's the best we can do without too much trouble.
withContext ::
Stack.HasCallStack =>
Text ->
[Context] ->
Task e b ->
Task e b
withContext name contexts task =
Stack.withFrozenCallStack
Internal.tracingSpan
name
( Platform.finally
task
( do
Platform.setTracingSpanDetails (LogContexts contexts)
Platform.setTracingSpanSummary name
)
)
--
-- CONTEXT
--
-- | A key-value pair that can be added to a log context. All log expressions
-- within the context will always log this key-value pair.
context :: (Show a, Aeson.ToJSON a) => Text -> a -> Context
context = Context
-- | Extra information to attach to a log message. It is passed a string key
-- defining what the data is and a value with a @ToJSON@ instance.
data Context where
Context :: (Show a, Aeson.ToJSON a) => Text -> a -> Context
deriving instance Show Context
-- | A set of log contexts.
newtype LogContexts
= LogContexts [Context]
instance Aeson.ToJSON LogContexts where
toJSON (LogContexts contexts) =
contexts
|> map (\(Context key val) -> key .= val)
|> Aeson.object
toEncoding (LogContexts contexts) =
contexts
|> Prelude.foldMap (\(Context key val) -> key .= val)
|> Aeson.pairs
instance Internal.TracingSpanDetails LogContexts
--
-- SECRET
--
-- | Wrap a value in a secret to prevent it from being accidentally logged.
--
-- > Debug.log "Logging a secret" (mkSecret "My PIN is 1234")
-- > --> Logging a secret: Secret *****
mkSecret :: a -> Secret a
mkSecret = Secret
-- | Retrieve the original value from a secret. Be very careful with this and ask
-- yourself: is there really no way I can pass this value on as a secret
-- further before I need to unwrap it?
--
-- The longer a value is wrapped in a Secret, the smaller the odds of it
-- accidentally being logged.
unSecret :: Secret a -> a
unSecret (Secret x) = x
-- | Distinguishes data that is secret and should not be logged.
--
-- Please be careful when defining or altering instances for this data type.
-- There's a good chance we will leak credentials, PII, or
-- other equally sensitive information.
newtype Secret a
= Secret a
deriving (Prelude.Eq, Prelude.Functor)
instance Prelude.Applicative Secret where
Secret f <*> Secret x = Secret (f x)
pure = Secret
-- | N.B. This instance of 'Show' is not law abiding.
--
-- This instance exists because we sometimes use 'Secret' in data types
-- that have to derive 'Show' (due to other constraints on those data types).
--
-- This is not a pattern to follow; it's an exception.
instance Show (Secret a) where
showsPrec p _ =
Text.Show.showParen (p > 10) (Text.Show.showString "Secret \"*****\"")
instance Aeson.ToJSON (Secret a) where
toJSON _ = Aeson.String "Secret *****"
--
-- TRIAGE
--
data LogLevel
= Debug
| Info
| Warn
| Error
deriving (Generic, Show)
instance Aeson.ToJSON LogLevel
-- ReportAsFailed marks the request as a failure in logging, but has no impact on the resulting Task. E.g. will not trigger a 500 error but will report an error to, e.g. BugSnag.
data ReportStatus = ReportAsFailed | ReportAsSucceeded
log :: Stack.HasCallStack => Text -> ReportStatus -> [Context] -> Task e ()
log msg reportStatus contexts =
Internal.tracingSpan msg <| do
Platform.setTracingSpanDetails (LogContexts contexts)
case reportStatus of
ReportAsSucceeded -> Task.succeed ()
ReportAsFailed -> Platform.markTracingSpanFailed