{-# LANGUAGE DeriveFunctor #-}
{-# LANGUAGE DeriveTraversable #-}
{-# LANGUAGE GADTs #-}
-- | 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
info,
userIsAnnoyed,
userIsConfused,
userIsPained,
userIsBlocked,
withContext,
context,
-- * Secrets
Secret,
mkSecret,
unSecret,
-- * For use in observability modules
Context (..),
LogContexts (..),
TriageInfo (..),
Impact (..),
)
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 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 contexts
-- | A log message when the user is annoyed, but not blocked.
--
-- > Log.userIsAnnoyed
-- > "We poked the user unnecessarily."
-- > "Try to stop poking the user."
-- > [ Log.context "The type of poking stick" poker ]
userIsAnnoyed :: Stack.HasCallStack => Text -> Text -> [Context] -> Task e ()
userIsAnnoyed message advisory contexts =
let triage = TriageInfo UserAnnoyed advisory
in Stack.withFrozenCallStack
log
message
ReportAsFailed
(Context "triage" triage : contexts)
-- | Like 'userIsAnnoyed', but when the user is userIsConfused.
userIsConfused :: Stack.HasCallStack => Text -> Text -> [Context] -> Task e ()
userIsConfused message advisory contexts =
let triage = TriageInfo UserConfused advisory
in Stack.withFrozenCallStack
log
message
ReportAsFailed
(Context "triage" triage : contexts)
-- | Like 'userIsAnnoyed', but when the user is in pain.
userIsPained :: Stack.HasCallStack => Text -> Text -> [Context] -> Task e ()
userIsPained message advisory contexts =
let triage = TriageInfo UserInPain advisory
in Stack.withFrozenCallStack
log
message
ReportAsFailed
(Context "triage" triage : contexts)
-- | Like 'userIsAnnoyed', but when the user is blocked.
userIsBlocked :: Stack.HasCallStack => Text -> Text -> [Context] -> Task e ()
userIsBlocked message advisory contexts =
let triage = TriageInfo UserBlocked advisory
in Stack.withFrozenCallStack
log
message
ReportAsFailed
(Context "triage" triage : 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 :: (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 :: Aeson.ToJSON a => Text -> a -> 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
--
-- | A logged message for log levels warning and above. Because these levels
-- indicate a (potential) problem we want to provide some additional data that
-- would help a triager figure out what next steps to take.
data TriageInfo = TriageInfo
{ impact :: Impact,
advisory :: Text
}
deriving (Generic)
instance Aeson.ToJSON TriageInfo
-- | Classification of the levels of impact an issue might have on end-users.
data Impact
= UserAnnoyed
| UserConfused
| UserInPain
| UserBlocked
deriving (Show)
instance Aeson.ToJSON Impact where
toJSON = Aeson.toJSON << impactToText
toEncoding = Aeson.toEncoding << impactToText
impactToText :: Impact -> Text
impactToText kind =
case kind of
UserAnnoyed -> "This is causing inconveniences to users but they will be able to achieve want they want."
UserBlocked -> "User is blocked from performing an action."
UserConfused -> "The UI did something unexpected and it's unclear why."
UserInPain -> "This is causing pain to users and workaround is not obvious."
-- 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