packages feed

nri-prelude-0.4.0.0: src/Log.hs

{-# 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