packages feed

nri-observability (empty) → 0.1.0.0

raw patch · 20 files changed

+2260/−0 lines, 20 filesdep +aesondep +aeson-prettydep +async

Dependencies added: aeson, aeson-pretty, async, base, bugsnag-hs, bytestring, directory, hostname, http-client, http-client-tls, nri-env-parser, nri-prelude, random, safe-exceptions, stm, text, time, unordered-containers

Files

+ CHANGELOG.md view
@@ -0,0 +1,3 @@+# 0.1.0.0++- Initial release.
+ LICENSE view
@@ -0,0 +1,29 @@+BSD 3-Clause License++Copyright (c) 2021, NoRedInk+All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++* Redistributions of source code must retain the above copyright notice, this+  list of conditions and the following disclaimer.++* Redistributions in binary form must reproduce the above copyright notice,+  this list of conditions and the following disclaimer in the documentation+  and/or other materials provided with the distribution.++* Neither the name of the copyright holder nor the names of its+  contributors may be used to endorse or promote products derived from+  this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,12 @@+# nri-observability++A library for reporting observability data collected in [nri-prelude][]-backed Haskell applications to various backends.++This library exposes two categories of modules:++- `Reporter` modules for sending data to various backends.+- `Log` modules defining `Details` types for common kinds of tracing spans.++Both can be mixed and matched with `Reporter` and `Details` types defined elsewhere.++[nri-prelude]: https://github.com/NoRedInk/haskell-libraries/tree/trunk/nri-prelude
+ nri-observability.cabal view
@@ -0,0 +1,114 @@+cabal-version: 1.18++-- This file has been generated from package.yaml by hpack version 0.34.2.+--+-- see: https://github.com/sol/hpack++name:           nri-observability+version:        0.1.0.0+synopsis:       Report log spans collected by nri-prelude.+description:    Please see the README at <https://github.com/NoRedInk/haskell-libraries/tree/trunk/observability>.+category:       Web+homepage:       https://github.com/NoRedInk/haskell-libraries#readme+bug-reports:    https://github.com/NoRedInk/haskell-libraries/issues+author:         NoRedInk+maintainer:     haskell-open-source@noredink.com+copyright:      2021 NoRedInk Corp.+license:        BSD3+license-file:   LICENSE+build-type:     Simple+extra-doc-files:+    README.md+    LICENSE+    CHANGELOG.md++source-repository head+  type: git+  location: https://github.com/NoRedInk/haskell-libraries+  subdir: observability++library+  exposed-modules:+      Log.HttpRequest+      Log.RedisCommands+      Log.SqlQuery+      Reporter.Bugsnag+      Reporter.Dev+      Reporter.File+  other-modules:+      Platform.ReporterHelpers+      Platform.Timer+      Reporter.Bugsnag.Internal+      Reporter.Dev.Internal+      Reporter.File.Internal+      Paths_nri_observability+  hs-source-dirs:+      src+  default-extensions: DataKinds DeriveGeneric FlexibleContexts FlexibleInstances GeneralizedNewtypeDeriving MultiParamTypeClasses NamedFieldPuns NoImplicitPrelude OverloadedStrings PartialTypeSignatures ScopedTypeVariables Strict TypeOperators+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wpartial-fields -Wredundant-constraints -Wincomplete-uni-patterns -fplugin=NriPrelude.Plugin+  build-depends:+      aeson >=1.4.6.0 && <1.6+    , aeson-pretty >=0.8.0 && <0.9+    , async >=2.2.2 && <2.3+    , base >=4.12.0.0 && <4.15+    , bugsnag-hs >=0.1.0.0 && <0.3+    , bytestring >=0.10.8.2 && <0.12+    , directory >=1.3.3.0 && <1.4+    , hostname >=1.0 && <1.1+    , http-client >=0.6.0 && <0.8+    , http-client-tls >=0.3.0 && <0.4+    , nri-env-parser >=0.1.0.0 && <0.2+    , nri-prelude >=0.1.0.0 && <0.6+    , random >=1.1 && <1.3+    , safe-exceptions >=0.1.7.0 && <1.3+    , stm >=2.4 && <2.6+    , text >=1.2.3.1 && <1.3+    , time >=1.8.0.2 && <1.12+    , unordered-containers >=0.2.0.0 && <0.3+  default-language: Haskell2010++test-suite tests+  type: exitcode-stdio-1.0+  main-is: Main.hs+  other-modules:+      Spec.Platform.Timer+      Spec.Reporter.Bugsnag+      Spec.Reporter.Dev+      Spec.Reporter.File+      Log.HttpRequest+      Log.RedisCommands+      Log.SqlQuery+      Platform.ReporterHelpers+      Platform.Timer+      Reporter.Bugsnag+      Reporter.Bugsnag.Internal+      Reporter.Dev+      Reporter.Dev.Internal+      Reporter.File+      Reporter.File.Internal+      Paths_nri_observability+  hs-source-dirs:+      tests+      src+  default-extensions: DataKinds DeriveGeneric FlexibleContexts FlexibleInstances GeneralizedNewtypeDeriving MultiParamTypeClasses NamedFieldPuns NoImplicitPrelude OverloadedStrings PartialTypeSignatures ScopedTypeVariables Strict TypeOperators ExtendedDefaultRules+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wpartial-fields -Wredundant-constraints -Wincomplete-uni-patterns -fplugin=NriPrelude.Plugin -threaded -rtsopts "-with-rtsopts=-N -T" -fno-warn-type-defaults+  build-depends:+      aeson >=1.4.6.0 && <1.6+    , aeson-pretty >=0.8.0 && <0.9+    , async >=2.2.2 && <2.3+    , base >=4.12.0.0 && <4.15+    , bugsnag-hs >=0.1.0.0 && <0.3+    , bytestring >=0.10.8.2 && <0.12+    , directory >=1.3.3.0 && <1.4+    , hostname >=1.0 && <1.1+    , http-client >=0.6.0 && <0.8+    , http-client-tls >=0.3.0 && <0.4+    , nri-env-parser >=0.1.0.0 && <0.2+    , nri-prelude >=0.1.0.0 && <0.6+    , random >=1.1 && <1.3+    , safe-exceptions >=0.1.7.0 && <1.3+    , stm >=2.4 && <2.6+    , text >=1.2.3.1 && <1.3+    , time >=1.8.0.2 && <1.12+    , unordered-containers >=0.2.0.0 && <0.3+  default-language: Haskell2010
+ src/Log/HttpRequest.hs view
@@ -0,0 +1,77 @@+-- | A module for creating great logs in code that send or receive HTTP+-- requests.+module Log.HttpRequest+  ( Details,+    Incoming (Incoming),+    Outgoing (Outgoing),+    emptyDetails,+    method,+    host,+    path,+    queryString,+    headers,+    httpVersion,+    endpoint,+    status,+  )+where++import qualified Data.Aeson as Aeson+import qualified Dict++-- | A type describing an http request.+--+-- > emptyDetails+-- >   { method = Just "GET"+-- >   , host = Just "https://noredink.com"+-- >   }+data Details = Details+  { -- | The method of the http request.+    method :: Maybe Text,+    -- | The host the http request is made to.+    host :: Maybe Text,+    -- | The path portion of the request URI.+    path :: Maybe Text,+    -- | The query string portion of the request URI.+    queryString :: Maybe Text,+    -- | The headers on the request. Do not pass headers with sensitive+    -- information in there, filter them out first!+    headers :: Dict.Dict Text Text,+    -- | The version of the http protocol used.+    httpVersion :: Maybe Text,+    -- | The endpoint called. This is like the path, but with the dynamic parts+    -- of the path replaced with arguments.+    --+    -- For example:+    -- > path     /teeth/upperleft/12+    -- > endpoint /teeth/:quadrant/:number+    endpoint :: Maybe Text,+    -- | The response status of the request.+    status :: Maybe Int+  }+  deriving (Generic)++-- | An empty details value to be modified by you.+emptyDetails :: Details+emptyDetails = Details Nothing Nothing Nothing Nothing Dict.empty Nothing Nothing Nothing++instance Aeson.ToJSON Details where+  toJSON = Aeson.genericToJSON infoEncodingOptions+  toEncoding = Aeson.genericToEncoding infoEncodingOptions++infoEncodingOptions :: Aeson.Options+infoEncodingOptions =+  Aeson.defaultOptions+    { Aeson.fieldLabelModifier = Aeson.camelTo2 ' ',+      Aeson.omitNothingFields = True+    }++newtype Incoming = Incoming Details+  deriving (Aeson.ToJSON)++instance Platform.TracingSpanDetails Incoming++newtype Outgoing = Outgoing Details+  deriving (Aeson.ToJSON)++instance Platform.TracingSpanDetails Outgoing
+ src/Log/RedisCommands.hs view
@@ -0,0 +1,50 @@+-- | A module for creating great logs in code that sends commands to Redis.+module Log.RedisCommands+  ( Details,+    emptyDetails,+    commands,+    host,+    port,+  )+where++import qualified Data.Aeson as Aeson++-- | A type describing redis commands.+--+-- > emptyDetails+-- >   { commands = [ "GET weather" ]+-- >   , host = Just "my-redis-host"+-- >   }+data Details = Details+  { -- | The commands that were sent to redis. Because Redis support for+    -- pipelining and transactions it's possible for one logical operation from+    -- the application perspective to contain multiple commands.+    --+    -- These commands are expected not to contain any sensitive information.+    -- Make sure sensitive values are mocked out, for example by replacing them+    -- with *****.+    commands :: List Text,+    -- | The host the redis commands are sent too.+    host :: Maybe Text,+    -- | The port redis is running on.+    port :: Maybe Int+  }+  deriving (Generic)++-- | An empty details value to be modified by you.+emptyDetails :: Details+emptyDetails = Details [] Nothing Nothing++instance Aeson.ToJSON Details where+  toJSON = Aeson.genericToJSON infoEncodingOptions+  toEncoding = Aeson.genericToEncoding infoEncodingOptions++infoEncodingOptions :: Aeson.Options+infoEncodingOptions =+  Aeson.defaultOptions+    { Aeson.fieldLabelModifier = Aeson.camelTo2 ' ',+      Aeson.omitNothingFields = True+    }++instance Platform.TracingSpanDetails Details
+ src/Log/SqlQuery.hs view
@@ -0,0 +1,70 @@+-- | A module for creating great logs in code that makes SQL queries.+module Log.SqlQuery+  ( Details,+    emptyDetails,+    query,+    queryTemplate,+    sqlOperation,+    queriedRelation,+    databaseType,+    host,+    port,+    database,+    rowsReturned,+    mysql,+    postgresql,+  )+where++import qualified Data.Aeson as Aeson++-- | A type describing an SQL query.+--+-- > emptyDetails+-- >   { query = Just (Log.mkSecret "SELECT cuddles FROM puppies")+-- >   , database = Just postgresql+-- >   }+data Details = Details+  { -- | The full query we're executing.+    query :: Maybe (Log.Secret Text),+    -- | The query we're executing with values mocked out.+    queryTemplate :: Maybe Text,+    -- | The SQL operation we're performing (SELECT / INSERT / DELETE / ...).+    sqlOperation :: Maybe Text,+    -- | The primary relation of the query.+    queriedRelation :: Maybe Text,+    -- | The type of database.+    databaseType :: Maybe Text,+    -- | Database host the connection is made to.+    host :: Maybe Text,+    -- | Port the database is running on.+    port :: Maybe Int,+    -- | The name of the database that is being queried.+    database :: Maybe Text,+    -- | The amount of rows this query returned.+    rowsReturned :: Maybe Int+  }+  deriving (Generic)++-- | An empty details value to be modified by you.+emptyDetails :: Details+emptyDetails = Details Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing++instance Aeson.ToJSON Details where+  toJSON = Aeson.genericToJSON options+  toEncoding = Aeson.genericToEncoding options++options :: Aeson.Options+options =+  Aeson.defaultOptions+    { Aeson.fieldLabelModifier = Aeson.camelTo2 ' ',+      Aeson.omitNothingFields = True+    }++instance Platform.TracingSpanDetails Details++mysql :: Text+mysql = "MySQL"++postgresql :: Text+postgresql = "PostgreSQL"
+ src/Platform/ReporterHelpers.hs view
@@ -0,0 +1,61 @@+module Platform.ReporterHelpers (toHashMap, srcString) where++import qualified Data.Aeson as Aeson+import qualified Data.Foldable as Foldable+import qualified Data.HashMap.Strict as HashMap+import qualified GHC.Stack as Stack+import qualified List+import qualified Text+import qualified Prelude++-- | Our span details are arbitrary JSON structures, but some of our reporters+-- require metadata as a flast list of key,value pairs.+--+-- Given a type that has the following JSON representation:+--+--     {+--       "treasure": {+--         "coords: { "x": 12, "y" 14 },+--         "worth": "Tons!"+--       }+--     }+--+-- This function will create a flat list of key,value pairs like this:+--+--     HashMap.fromList+--       [ ("treasure.coords.x", "12"   )+--       , ("treasure.coords.y", "14"   )+--       , ("treasure.worth"   , "Tons!")+--       ]+toHashMap :: Aeson.ToJSON a => a -> HashMap.HashMap Text Text+toHashMap x =+  case Aeson.toJSON x of+    Aeson.Object dict ->+      HashMap.foldlWithKey'+        (\acc key value -> acc ++ jsonAsText key value)+        HashMap.empty+        dict+    val -> jsonAsText "value" val++jsonAsText :: Text -> Aeson.Value -> HashMap.HashMap Text Text+jsonAsText key val =+  case val of+    Aeson.Object dict ->+      HashMap.foldlWithKey'+        (\acc key2 value -> acc ++ jsonAsText (key ++ "." ++ key2) value)+        HashMap.empty+        dict+    Aeson.Array vals ->+      Foldable.toList vals+        |> List.indexedMap (\i elem -> jsonAsText (key ++ "." ++ Text.fromInt i) elem)+        |> HashMap.unions+    Aeson.String str -> HashMap.singleton key str+    Aeson.Number n -> HashMap.singleton key (Text.fromList (Prelude.show n))+    Aeson.Bool bool -> HashMap.singleton key (Text.fromList (Prelude.show bool))+    Aeson.Null -> HashMap.empty++srcString :: Stack.SrcLoc -> Text+srcString frame =+  Text.fromList (Stack.srcLocFile frame)+    ++ ":"+    ++ Text.fromInt (Prelude.fromIntegral (Stack.srcLocStartLine frame))
+ src/Platform/Timer.hs view
@@ -0,0 +1,124 @@+{-# OPTIONS_GHC -fno-cse #-}++-- | Helper functions for converting the timestamp format used in our `Span`+-- data to the time formats used by the various platforms we report to.+module Platform.Timer+  ( Timer (Timer),+    mkTimer,+    toUTC,+    toLocal,+    toPosix,+    toPosixMicroseconds,+    toISO8601,+    difference,+    durationInUs,+  )+where++import qualified Control.Concurrent.MVar as MVar+import qualified Data.Time.Clock as Clock+import qualified Data.Time.Clock.POSIX as Clock.POSIX+import qualified Data.Time.Format as Format+import qualified Data.Time.LocalTime as LocalTime+import qualified Data.Word as Word+import qualified GHC.Clock+import qualified Platform+import qualified System.IO.Unsafe+import qualified Prelude++-- | Our spans' timestamps are produced by the `GHC.Clock` module and consist of+-- the amount of time passed since some arbitrary (but constant) moment in the+-- past. This is the faster and more accurate way to measure precisely what the+-- running time of spans is. This type helpers convert these times into regular+-- dates.+data Timer = Timer+  { -- | The POSIX time in microseconds that corresponds with t=0 according+    -- to `GHC.Clock`. We can use this to calculate other `GHC.Clock`+    -- values.+    tzero :: Word.Word64,+    -- | The timezone of the machine this code is running on. Useful for+    -- printing local times in development reporters.+    timezone :: LocalTime.TimeZone+  }++-- | Create a timer, then cache it. When asked again return the previously+-- created timer.+--+-- Passing separate timers to multiple reporters could result in those reporters+-- disagreeing very subtly on the exact time when events happen. Having a single+-- timer prevents this from happening.+mkTimer :: Prelude.IO Timer+mkTimer =+  MVar.modifyMVar+    timerVar+    ( \maybeTimer ->+        case maybeTimer of+          Just timer -> Prelude.pure (Just timer, timer)+          Nothing -> do+            -- 'Sync our clocks', to find our how monotonic time and actual time relate.+            nowTime <- Clock.POSIX.getPOSIXTime+            nowClock <- GHC.Clock.getMonotonicTimeNSec+            timezone <- LocalTime.getCurrentTimeZone+            let tzero = Prelude.floor (1e6 * nowTime) - (nowClock `Prelude.div` 1000)+            let timer = Timer {tzero, timezone}+            Prelude.pure (Just timer, timer)+    )++{-# NOINLINE timerVar #-}+timerVar :: MVar.MVar (Maybe Timer)+timerVar = System.IO.Unsafe.unsafePerformIO (MVar.newMVar Nothing)++toUTC :: Timer -> Platform.MonotonicTime -> Clock.UTCTime+toUTC timer clock =+  toPosix timer clock+    |> Clock.POSIX.posixSecondsToUTCTime++toLocal :: Timer -> Platform.MonotonicTime -> LocalTime.LocalTime+toLocal timer clock =+  toUTC timer clock+    |> LocalTime.utcToLocalTime (timezone timer)++toPosixMicroseconds :: Timer -> Platform.MonotonicTime -> Word.Word64+toPosixMicroseconds timer clock = tzero timer + Platform.inMicroseconds clock++toPosix :: Timer -> Platform.MonotonicTime -> Clock.POSIX.POSIXTime+toPosix timer clock =+  toPosixMicroseconds timer clock+    |> Prelude.fromIntegral+    |> (*) 1e-6++toISO8601 :: Timer -> Platform.MonotonicTime -> Text+toISO8601 timer clock =+  toUTC timer clock+    |> Format.formatTime Format.defaultTimeLocale "%FT%T%QZ"+    |> Text.fromList++-- | We have to be careful when calculating the difference between two times.+-- Because they are unsigned (don't allow negative numbers), subtracting times+-- in the wrong order is going to result in very large numbers:+--+--     ghci> import GHC.Word+--     ghci> 5 - 2 :: Word64+--     3+--     ghci> 2 - 5 :: Word64+--     18446744073709551613+--+-- The span data we get from Platform should ensure end times always come+-- before start times. If they're not though one of these extremely long span+-- durations can have a major effect on request duration statistics.+--+-- This function performs some defensive programming to prevent flukes from+-- doing major damage.+difference :: Platform.MonotonicTime -> Platform.MonotonicTime -> Platform.MonotonicTime+difference start end =+  if end > start+    then end - start+    else 0++-- | Get the time covered by a duration in microseconds.+durationInUs :: Platform.TracingSpan -> Word.Word64+durationInUs span =+  difference+    (Platform.started span)+    (Platform.finished span)+    |> Platform.inMicroseconds
+ src/Reporter/Bugsnag.hs view
@@ -0,0 +1,18 @@+-- | This reporter sends tracing information for failing requests to Bugsnag,+-- a platform for monitoring applications. Learn more about Bugsnag on their+-- website:+--+-- <https://www.bugsnag.com/>+module Reporter.Bugsnag+  ( Internal.report,+    Internal.Handler,+    Internal.handler,+    Internal.Settings,+    Internal.decoder,+    Internal.apiKey,+    Internal.appName,+    Internal.appEnvironment,+  )+where++import qualified Reporter.Bugsnag.Internal as Internal
+ src/Reporter/Bugsnag/Internal.hs view
@@ -0,0 +1,567 @@+-- | Reporting to Bugsnag.+--+-- This reporter reports failures to Bugsnag. It does nothing for requests that+-- completed without error.+module Reporter.Bugsnag.Internal where++import qualified Control.Exception.Safe as Exception+import Data.Aeson ((.=))+import qualified Data.Aeson as Aeson+import qualified Data.HashMap.Strict as HashMap+import qualified Data.List+import qualified Data.Proxy as Proxy+import qualified Data.Text.IO+import qualified Data.Typeable as Typeable+import qualified Dict+import qualified Environment+import qualified GHC.Stack as Stack+import qualified List+import qualified Log.HttpRequest as HttpRequest+import qualified Log.RedisCommands as RedisCommands+import qualified Log.SqlQuery as SqlQuery+import qualified Network.Bugsnag as Bugsnag+import qualified Network.HTTP.Client as HTTP+import qualified Network.HTTP.Client.TLS as HTTP.TLS+import qualified Network.HostName+import qualified Platform+import qualified Platform.ReporterHelpers as Helpers+import qualified Platform.Timer as Timer+import qualified Prelude++-- This function takes the root span of a completed request and reports it to+-- Bugsnag, if there has been a failure. A request that completed succesfully+-- is not reported.+--+-- If we squint a bit, the rough shape of data that Bugsnag expects of us is:+--+--    event {attributes} [breadcrumbs]+--+-- Meaning: we can use various attributes to describe an event and in addition+-- pass a list of "breadcrumbs", other events that took place before the one the+-- report we're making is about.+--+-- The root span we pass in is a tree structure. It can have child spans, which+-- in turn can have child spans, etc. Each span is marked with whether it+-- succeeded or failed. If one of the children of a span failed, the span itself+-- failed too.+--+-- To turn this tree structure into the data that Bugsnag expects we're going to+-- take the following approach. First we're going to find the 'root cause span'.+-- This is the most recently started span that failed. The data in this span and+-- it's parents is going to make up the main event to Bugsnag. All other spans+-- that completed before the root cause span started we'll turn into+-- breadcrumbs. For some span tree it might look like this:+--+--     ^     failed span, a = 1            -> event { a = 1,+--     t         succeeded span+--     i         failed span, b = 2        ->         b = 2,+--     m             failed span, c = 3    ->         c = 3 }+--     e                 succeeded span    ->       [ breadcrumb1+--     ^         succeeded span            ->       , breadcrumb2 ]+--+-- A span that happened _after_ the root cause event completed we're not+-- reporting.+--+-- Example usage:+--+-- > settings <- Bugsnag.decode+-- > handler <- Bugsnag.handler settings+-- > Bugsnag.report handler "request-id" span+report :: Handler -> Text -> Platform.TracingSpan -> Prelude.IO ()+report Handler {http, timer, defaultEvent, apiKey'} requestId span =+  if failed span+    then send http apiKey' (toEvent requestId timer defaultEvent span)+    else Prelude.pure ()++-- | Contextual information this reporter needs to do its work. You can create+-- one using 'handler'.+data Handler = Handler+  { http :: HTTP.Manager,+    timer :: Timer.Timer,+    defaultEvent :: Bugsnag.Event,+    apiKey' :: Log.Secret Bugsnag.ApiKey+  }++-- | Create a 'Handler' for a specified set of 'Settings'. Do this once when+-- your application starts and reuse the 'Handler' you get.+handler :: Settings -> Prelude.IO Handler+handler settings = do+  http <- HTTP.TLS.getGlobalManager+  defaultEvent <- mkDefaultEvent settings+  timer <- Timer.mkTimer+  Prelude.pure+    Handler+      { http,+        timer,+        defaultEvent,+        apiKey' = apiKey settings+      }++send :: HTTP.Manager -> Log.Secret Bugsnag.ApiKey -> Bugsnag.Event -> Prelude.IO ()+send manager key event = do+  result <- Bugsnag.sendEvents manager (Log.unSecret key) [event]+  case result of+    Prelude.Left err -> Exception.throwIO err+    Prelude.Right _ -> Prelude.pure ()++toEvent :: Text -> Timer.Timer -> Bugsnag.Event -> Platform.TracingSpan -> Bugsnag.Event+toEvent requestId timer defaultEvent span =+  rootCause [] emptyCrumbs timer rootEvent span+  where+    rootEvent =+      defaultEvent+        { Bugsnag.event_metaData =+            mergeMetaData+              (Bugsnag.event_metaData defaultEvent)+              (Just rootMetaData)+        }+    rootMetaData =+      [ "response time in ms"+          .= ( Timer.durationInUs span+                 |> Prelude.fromIntegral+                 |> (*) 1e-3 ::+                 Float+             ),+        "megabytes allocated"+          .= ( Prelude.fromIntegral (Platform.allocated span)+                 / (1024 * 1024) ::+                 Float+             ),+        "request id" .= requestId+      ]+        |> Aeson.object+        |> HashMap.singleton "request"++-- | Find the most recently started span that failed. This span is closest to+-- the failure and we'll use the data in it and its parents to build the+-- exception we send to Bugsnag. We'll send information about spans that ran+-- before the root cause span started as breadcrumbs.+rootCause ::+  [Bugsnag.StackFrame] ->+  Crumbs ->+  Timer.Timer ->+  Bugsnag.Event ->+  Platform.TracingSpan ->+  Bugsnag.Event+rootCause frames breadcrumbs timer event span =+  let newFrames =+        case Platform.frame span of+          Nothing -> frames+          Just (name, src) -> toStackFrame name src : frames+      newEvent = decorateEventWithTracingSpanData span event+      childTracingSpans = Platform.children span+   in -- We're not interested in child spans that happened _after_ the root+      -- cause took place. These are not breadcrumbs (leading up to the error)+      -- nor can they have caused the error itself because they happened after.+      -- Since child spans are ordered most-recent first we can keep dropping+      -- child spans until we hit the one where the most recent error happened.+      case Data.List.dropWhile (not << failed) childTracingSpans of+        child : preErrorTracingSpans ->+          rootCause+            newFrames+            ( breadcrumbs+                |> followedBy (addCrumb (startBreadcrumb timer span))+                |> followedBy (addCrumbs timer preErrorTracingSpans)+            )+            timer+            newEvent+            child+        [] ->+          newEvent+            { Bugsnag.event_exceptions = [toException newFrames span],+              Bugsnag.event_breadcrumbs =+                -- This is the innermost span that failed, so all it's children+                -- succeeded. We're going to assume that the error happened+                -- after the last of these child spans, making all child spans+                -- breadcrumbs.+                breadcrumbs+                  |> followedBy (addCrumbs timer childTracingSpans)+                  |> crumbsAsList+                  |> List.reverse+                  |> Just,+              Bugsnag.event_unhandled = case Platform.succeeded span of+                Platform.Succeeded -> Nothing+                -- `Failed` indicates a span was marked as failed by the application+                -- author. Something went wrong, but we wrote logic to handle it.+                Platform.Failed -> Just False+                -- `FailedWith` indicates a Haskell exception was thrown. We don't throw+                -- in our applications, so this indicates a library is doing something+                -- we didn't expect.+                Platform.FailedWith _ -> Just True+            }++-- | This function is passed a list of spans and outputs a type representing a+-- flat list of breadcrumbs.+--+-- Each span can contain child spans requiring us to recurse.+--+-- Our Bugsnag library asks for a value of type `[Bugsnag.Breadcrumb]`, so a+-- list. It's very performant to add single items to the front of a list, but+-- appending two lists is costly. So we want to avoid appending in our+-- breadcrumb collection, because if the span tree gets large we'd be doing a+-- lot of it.+--+-- To help us avoid doing appends we create a helper type `Crumbs a`. The only+-- helper function it exposes for adding a breadcrumb is one that cons that+-- breadcrumb to the front of the list, ensuring no appends take place.+addCrumbs :: Timer.Timer -> [Platform.TracingSpan] -> Crumbs+addCrumbs timer spans =+  case spans of+    [] -> emptyCrumbs+    span : after ->+      addCrumbs timer after+        |> followedBy (addCrumbsForTracingSpan timer span)++addCrumbsForTracingSpan :: Timer.Timer -> Platform.TracingSpan -> Crumbs+addCrumbsForTracingSpan timer span =+  case Platform.children span of+    [] ->+      addCrumb (doBreadcrumb timer span)+    children ->+      addCrumb (startBreadcrumb timer span)+        |> followedBy (addCrumbs timer children)+        |> followedBy (addCrumb (endBreadcrumb timer span))++-- | A type representing a list of breadcrumbs. We're not using just a list+-- directly, because then in constructing the full list of breadcrumbs we'd have+-- to do list appends often, which aren't very efficient. Instead we store a+-- function that describes creation of the eventual list of breadcrumbs from an+-- initially empty list.+newtype Crumbs = Crumbs ([Bugsnag.Breadcrumb] -> [Bugsnag.Breadcrumb])++emptyCrumbs :: Crumbs+emptyCrumbs = Crumbs identity++-- | Combine breadcrumbs, placing one set after the other.+--+--     earlyCrumbs+--       |> followedBy laterCrumbs+followedBy :: Crumbs -> Crumbs -> Crumbs+followedBy (Crumbs f) (Crumbs g) = Crumbs (f << g)++crumbsAsList :: Crumbs -> [Bugsnag.Breadcrumb]+crumbsAsList (Crumbs f) = f []++addCrumb :: Bugsnag.Breadcrumb -> Crumbs+addCrumb crumb = Crumbs (crumb :)++endBreadcrumb :: Timer.Timer -> Platform.TracingSpan -> Bugsnag.Breadcrumb+endBreadcrumb timer span =+  Bugsnag.defaultBreadcrumb+    { Bugsnag.breadcrumb_name = "Finished: " ++ Platform.name span,+      Bugsnag.breadcrumb_type = Bugsnag.logBreadcrumbType,+      Bugsnag.breadcrumb_timestamp = Timer.toISO8601 timer (Platform.finished span)+    }++startBreadcrumb :: Timer.Timer -> Platform.TracingSpan -> Bugsnag.Breadcrumb+startBreadcrumb timer span =+  (doBreadcrumb timer span)+    { Bugsnag.breadcrumb_name = "Starting: " ++ Platform.name span+    }++doBreadcrumb :: Timer.Timer -> Platform.TracingSpan -> Bugsnag.Breadcrumb+doBreadcrumb timer span =+  let defaultBreadcrumb =+        Bugsnag.defaultBreadcrumb+          { Bugsnag.breadcrumb_name = Platform.name span,+            Bugsnag.breadcrumb_type = Bugsnag.manualBreadcrumbType,+            Bugsnag.breadcrumb_timestamp = Timer.toISO8601 timer (Platform.started span),+            Bugsnag.breadcrumb_metaData = stackFrameMetaData ++ durationMetaData+          }+      stackFrameMetaData =+        case Platform.frame span of+          Nothing -> Nothing+          Just (_, frame) ->+            Stack.srcLocFile frame ++ ":" ++ Prelude.show (Stack.srcLocStartLine frame)+              |> Text.fromList+              |> HashMap.singleton "stack frame"+              |> Just+      durationMetaData =+        Just+          ( HashMap.singleton+              "duration in milliseconds"+              ( Timer.durationInUs span+                  |> Prelude.fromIntegral+                  |> (*) 1e-3+                  |> Text.fromFloat+              )+          )+   in case Platform.details span of+        Nothing -> defaultBreadcrumb+        Just details -> customizeBreadcrumb span details defaultBreadcrumb++customizeBreadcrumb :: Platform.TracingSpan -> Platform.SomeTracingSpanDetails -> Bugsnag.Breadcrumb -> Bugsnag.Breadcrumb+customizeBreadcrumb span details breadcrumb =+  details+    |> Platform.renderTracingSpanDetails+      [ Platform.Renderer (outgoingHttpRequestAsBreadcrumb breadcrumb),+        Platform.Renderer (sqlQueryAsBreadcrumb breadcrumb),+        Platform.Renderer (redisQueryAsBreadcrumb breadcrumb),+        Platform.Renderer (logAsBreadcrumb span breadcrumb),+        Platform.Renderer (unknownAsBreadcrumb breadcrumb)+      ]+    |> Maybe.withDefault breadcrumb++outgoingHttpRequestAsBreadcrumb :: Bugsnag.Breadcrumb -> HttpRequest.Outgoing -> Bugsnag.Breadcrumb+outgoingHttpRequestAsBreadcrumb breadcrumb (HttpRequest.Outgoing details) =+  breadcrumb+    { Bugsnag.breadcrumb_type = Bugsnag.requestBreadcrumbType,+      Bugsnag.breadcrumb_metaData =+        Bugsnag.breadcrumb_metaData breadcrumb ++ Just (Helpers.toHashMap details)+    }++sqlQueryAsBreadcrumb :: Bugsnag.Breadcrumb -> SqlQuery.Details -> Bugsnag.Breadcrumb+sqlQueryAsBreadcrumb breadcrumb details =+  breadcrumb+    { Bugsnag.breadcrumb_type = Bugsnag.requestBreadcrumbType,+      Bugsnag.breadcrumb_metaData =+        Bugsnag.breadcrumb_metaData breadcrumb ++ Just (Helpers.toHashMap details)+    }++redisQueryAsBreadcrumb :: Bugsnag.Breadcrumb -> RedisCommands.Details -> Bugsnag.Breadcrumb+redisQueryAsBreadcrumb breadcrumb details =+  breadcrumb+    { Bugsnag.breadcrumb_type = Bugsnag.requestBreadcrumbType,+      Bugsnag.breadcrumb_metaData =+        Bugsnag.breadcrumb_metaData breadcrumb ++ Just (Helpers.toHashMap details)+    }++logAsBreadcrumb :: Platform.TracingSpan -> Bugsnag.Breadcrumb -> Log.LogContexts -> Bugsnag.Breadcrumb+logAsBreadcrumb span breadcrumb details =+  breadcrumb+    { Bugsnag.breadcrumb_type =+        if List.isEmpty (Platform.children span)+          then Bugsnag.logBreadcrumbType+          else Bugsnag.processBreadcrumbType,+      Bugsnag.breadcrumb_metaData =+        Bugsnag.breadcrumb_metaData breadcrumb ++ Just (Helpers.toHashMap details)+    }++unknownAsBreadcrumb :: Bugsnag.Breadcrumb -> Platform.SomeTracingSpanDetails -> Bugsnag.Breadcrumb+unknownAsBreadcrumb breadcrumb details =+  breadcrumb+    { Bugsnag.breadcrumb_type = Bugsnag.manualBreadcrumbType,+      Bugsnag.breadcrumb_metaData =+        Bugsnag.breadcrumb_metaData breadcrumb ++ Just (Helpers.toHashMap details)+    }++decorateEventWithTracingSpanData :: Platform.TracingSpan -> Bugsnag.Event -> Bugsnag.Event+decorateEventWithTracingSpanData span event =+  Platform.details span+    |> Maybe.andThen+      ( Platform.renderTracingSpanDetails+          [ Platform.Renderer (renderIncomingHttpRequest event),+            Platform.Renderer (renderLog event),+            Platform.Renderer (renderRemainingTracingSpanDetails span event)+          ]+      )+    |> Maybe.withDefault event++renderRemainingTracingSpanDetails :: Platform.TracingSpan -> Bugsnag.Event -> Platform.SomeTracingSpanDetails -> Bugsnag.Event+renderRemainingTracingSpanDetails span event details =+  event+    { Bugsnag.event_metaData =+        Aeson.toJSON details+          |> HashMap.singleton (Platform.name span)+          |> Just+          |> (++) (Bugsnag.event_metaData event)+    }++renderLog :: Bugsnag.Event -> Log.LogContexts -> Bugsnag.Event+renderLog event details =+  event+    { Bugsnag.event_metaData =+        Aeson.toJSON details+          |> HashMap.singleton "custom"+          |> HashMap.unionWith+            mergeJson+            (Bugsnag.event_metaData event |> Maybe.withDefault HashMap.empty)+          |> Just+    }++mergeJson :: Aeson.Value -> Aeson.Value -> Aeson.Value+mergeJson (Aeson.Object x) (Aeson.Object y) = Aeson.Object (HashMap.unionWith mergeJson x y)+mergeJson _ last = last++mergeMetaData ::+  Maybe (HashMap.HashMap Text Aeson.Value) ->+  Maybe (HashMap.HashMap Text Aeson.Value) ->+  Maybe (HashMap.HashMap Text Aeson.Value)+mergeMetaData Nothing x = x+mergeMetaData x Nothing = x+mergeMetaData (Just x) (Just y) = Just (HashMap.unionWith mergeJson x y)++renderIncomingHttpRequest ::+  Bugsnag.Event ->+  HttpRequest.Incoming ->+  Bugsnag.Event+renderIncomingHttpRequest event (HttpRequest.Incoming request) =+  event+    { Bugsnag.event_context = HttpRequest.endpoint request,+      Bugsnag.event_request =+        Just+          Bugsnag.defaultRequest+            { Bugsnag.request_httpMethod = HttpRequest.method request,+              Bugsnag.request_headers =+                HttpRequest.headers request+                  |> Dict.toList+                  |> HashMap.fromList+                  |> Just+            },+      -- Extra request data that Bugsnag doesn't ask for in its API, but which+      -- we can make appear on the 'request' tab anyway by logging it on the+      -- 'request' key of the event metadata.+      Bugsnag.event_metaData =+        mergeMetaData+          (Bugsnag.event_metaData event)+          ( [ "endpoint" .= HttpRequest.endpoint request,+              "http version" .= HttpRequest.httpVersion request,+              "response status" .= HttpRequest.status request,+              "path" .= HttpRequest.path request,+              "query string" .= HttpRequest.queryString request+            ]+              |> Aeson.object+              |> HashMap.singleton "request"+              |> Just+          )+    }++failed :: Platform.TracingSpan -> Bool+failed span =+  case Platform.succeeded span of+    Platform.Succeeded -> False+    Platform.Failed -> True+    Platform.FailedWith _ -> True++toException :: [Bugsnag.StackFrame] -> Platform.TracingSpan -> Bugsnag.Exception+toException frames span =+  case Platform.succeeded span of+    Platform.Succeeded -> Bugsnag.defaultException+    Platform.Failed ->+      Bugsnag.defaultException+        { Bugsnag.exception_errorClass = Platform.name span,+          Bugsnag.exception_stacktrace = frames+        }+    Platform.FailedWith (Exception.SomeException exception) ->+      Bugsnag.defaultException+        { Bugsnag.exception_errorClass = typeName exception,+          Bugsnag.exception_stacktrace = frames,+          Bugsnag.exception_message =+            Exception.displayException exception+              |> Text.fromList+              |> Just+        }++toStackFrame :: Text -> Stack.SrcLoc -> Bugsnag.StackFrame+toStackFrame functionName frame =+  Bugsnag.defaultStackFrame+    { Bugsnag.stackFrame_file = Text.fromList (Stack.srcLocFile frame),+      Bugsnag.stackFrame_lineNumber = Stack.srcLocStartLine frame,+      Bugsnag.stackFrame_columnNumber = Just (Stack.srcLocStartCol frame),+      Bugsnag.stackFrame_method = functionName,+      Bugsnag.stackFrame_inProject = Just True+    }++typeName :: forall a. Typeable.Typeable a => a -> Text+typeName _ =+  Typeable.typeRep (Proxy.Proxy :: Proxy.Proxy a)+    |> Prelude.show+    |> Text.fromList++-- | Configuration settings for this reporter. A value of this type can be read+-- from the environment using the 'decoder' function.+data Settings = Settings+  { -- | The Bugsnag API key to use. This determines which Bugsnag project your+    -- errors will end up in.+    --+    -- [@environment variable@] LOG_FILE+    -- [@default value@] app.log+    apiKey :: Log.Secret Bugsnag.ApiKey,+    -- | The name of this application. This will be attached to all bugsnag+    -- reports.+    --+    -- [@environment variable@] LOG_ROOT_NAMESPACE+    -- [@default value@] your-application-name-here+    appName :: Namespace,+    -- | The environment this application is running in. This will be attached+    -- to all bugsnage reports.+    --+    -- [@environment variable@] ENVIRONMENT+    -- [@default value@] development+    appEnvironment :: Environment+  }++-- | Read 'Settings' from environment variables. Default variables will be used+-- in case no environment variable is set for an option.+decoder :: Environment.Decoder Settings+decoder =+  Prelude.pure Settings+    |> andMap apiKeyDecoder+    |> andMap namespaceDecoder+    |> andMap environmentDecoder++apiKeyDecoder :: Environment.Decoder (Log.Secret Bugsnag.ApiKey)+apiKeyDecoder =+  Environment.variable+    Environment.Variable+      { Environment.name = "BUGSNAG_API_KEY",+        Environment.description = "The API key of the Bugsnag project we should send items too.",+        Environment.defaultValue = "*****"+      }+    (Environment.text |> map Bugsnag.apiKey |> Environment.secret)++newtype Namespace = Namespace {unNamespace :: Text}++namespaceDecoder :: Environment.Decoder Namespace+namespaceDecoder =+  Environment.variable+    Environment.Variable+      { Environment.name = "LOG_ROOT_NAMESPACE",+        Environment.description = "Root of the log namespace. This should be the name of the application.",+        Environment.defaultValue = "your-application-name-here"+      }+    (map Namespace Environment.text)++newtype Environment = Environment {unEnvironment :: Text}++environmentDecoder :: Environment.Decoder Environment+environmentDecoder =+  Environment.variable+    Environment.Variable+      { Environment.name = "ENVIRONMENT",+        Environment.description = "Environment to display in logs.",+        Environment.defaultValue = "development"+      }+    (map Environment Environment.text)++mkDefaultEvent :: Settings -> Prelude.IO Bugsnag.Event+mkDefaultEvent settings = do+  revision <- getRevision+  hostname <- Network.HostName.getHostName+  let appId = unNamespace (appName settings)+  let app =+        Bugsnag.defaultApp+          { Bugsnag.app_id = Just appId,+            -- Same format as what bugsnag-build-notify uses for appVersion+            Bugsnag.app_version = Just (appId ++ "-" ++ unRevision revision),+            Bugsnag.app_releaseStage = Just (unEnvironment (appEnvironment settings)),+            Bugsnag.app_type = Just "haskell"+          }+  let device =+        Bugsnag.defaultDevice+          { Bugsnag.device_hostname = Just (Text.fromList hostname)+          }+  Prelude.pure+    Bugsnag.defaultEvent+      { Bugsnag.event_app = Just app,+        Bugsnag.event_device = Just device+      }++newtype Revision = Revision {unRevision :: Text}++getRevision :: Prelude.IO Revision+getRevision = do+  eitherRevision <- Exception.tryAny <| Data.Text.IO.readFile "revision"+  case eitherRevision of+    Prelude.Left _err -> Prelude.pure (Revision "no revision file found")+    Prelude.Right version -> Prelude.pure (Revision version)
+ src/Reporter/Dev.hs view
@@ -0,0 +1,19 @@+-- | Reporting for development+--+-- This reporter logs basic information about requests in a human-readable+-- format, for use in a development console.+--+-- The development logs produced are quite sparse. To dig deeper this reporter+-- will also make tracing data available in the log-explorer tool. You can find+-- it here:+--+-- <https://github.com/NoRedInk/haskell-libraries/tree/trunk/nri-log-explorer>+module Reporter.Dev+  ( Internal.report,+    Internal.Handler,+    Internal.handler,+    Internal.cleanup,+  )+where++import qualified Reporter.Dev.Internal as Internal
+ src/Reporter/Dev/Internal.hs view
@@ -0,0 +1,114 @@+{-# LANGUAGE NumericUnderscores #-}++module Reporter.Dev.Internal where++import qualified Control.Concurrent+import qualified Control.Concurrent.Async as Async+import qualified Control.Concurrent.MVar as MVar+import qualified Control.Exception.Safe as Exception+import qualified Data.Text.IO+import qualified Data.Text.Lazy+import qualified Data.Text.Lazy.Builder as Builder+import qualified Log.HttpRequest as HttpRequest+import qualified Platform+import qualified Platform.Timer as Timer+import qualified Prelude++-- Print basic information about requests to stdout and make more detailed+-- information available to the log-explorer tool.+--+-- Example usage:+-- > handler <- Dev.handler+-- > Dev.report handler "request-id" span+report :: Handler -> Text -> Platform.TracingSpan -> Prelude.IO ()+report handler' _requestId span = do+  Platform.writeSpanToDevLog span+  MVar.putMVar (writeLock handler') (mkLog (timer handler') span)++mkLog :: Timer.Timer -> Platform.TracingSpan -> Builder.Builder+mkLog timer span =+  time timer span+    ++ " "+    ++ name span+    ++ " "+    ++ failed span++time :: Timer.Timer -> Platform.TracingSpan -> Builder.Builder+time timer span =+  Timer.toLocal timer (Platform.started span)+    |> Prelude.show+    |> Builder.fromString++name :: Platform.TracingSpan -> Builder.Builder+name span =+  Platform.details span+    |> Maybe.andThen+      ( Platform.renderTracingSpanDetails+          [ Platform.Renderer nameIncomingRequest+          ]+      )+    |> Maybe.withDefault (Builder.fromText (Platform.name span))++nameIncomingRequest :: HttpRequest.Incoming -> Builder.Builder+nameIncomingRequest (HttpRequest.Incoming details) =+  HttpRequest.endpoint details+    |> Maybe.withDefault "Incoming HTTP request"+    |> Builder.fromText++failed :: Platform.TracingSpan -> Builder.Builder+failed span =+  case Platform.succeeded span of+    Platform.Succeeded -> ""+    Platform.Failed -> "❌"+    Platform.FailedWith err ->+      "❌\n"+        ++ Builder.fromString (Exception.displayException err)++data Handler = Handler+  { timer :: Timer.Timer,+    -- If we let each request log to stdout directly the result will be lots+    -- of unreadable interleaved output from requests that are handled+    -- concurrently. To prevent this we use an MVar as a lock.+    --+    -- After a request is done it can write it's log to the MVar. If the+    -- MVar already contains a log this operation will block until the MVar+    -- is empty. We have a logging thread running separately that takes logs+    -- from the MVar and prints them to stdout one at a time.+    writeLock :: MVar.MVar Builder.Builder,+    loggingThread :: Async.Async ()+  }++-- | Create a 'Handler'. Do this once when your application starts and reuse+-- the 'Handler' you get.+handler :: Prelude.IO Handler+handler = do+  writeLock <- MVar.newEmptyMVar+  counter <- MVar.newMVar 0+  loggingThread <- Async.async (logLoop counter writeLock)+  timer <- Timer.mkTimer+  Prelude.pure Handler {timer, writeLock, loggingThread}++-- | Clean up your handler after you're done with it. Call this before your+-- application shuts down.+cleanup :: Handler -> Prelude.IO ()+cleanup = Async.cancel << loggingThread++-- | Waits for a log message to become available in the MVar, logs it, then+-- waits for the next one. This is intended to be ran on a separate thread.+logLoop :: MVar.MVar Int -> MVar.MVar Builder.Builder -> Prelude.IO ()+logLoop counter lock = do+  line <- MVar.takeMVar lock+  Builder.toLazyText line+    |> Data.Text.Lazy.toStrict+    |> Data.Text.IO.putStrLn+  ownCount <- MVar.modifyMVar counter (\n -> Prelude.pure (n + 1, n + 1))+  Async.concurrently_+    (logLoop counter lock)+    ( do+        -- After a few seconds of inactivity, advertise for log-explorer.+        Control.Concurrent.threadDelay 3_000_000 {- 3 seconds -}+        currentCount <- MVar.readMVar counter+        if ownCount == currentCount+          then Prelude.putStrLn "🕵️ Need more detail? Try running the `log-explorer` command!\n"+          else Prelude.pure ()+    )
+ src/Reporter/File.hs view
@@ -0,0 +1,26 @@+-- | Reporting to a file.+--+-- This reporter logs debugging information about completed requests to a file+-- or `stdout` (in that case, pass in `/dev/stdout` as the file to log to).+--+-- Every line this reporter logs is a JSON string. This 'structured logging'+-- output is optimized for external logging platforms that display these logs in+-- a pretty UI.+--+-- This logger supports sampling of successful requests, to help save money when+-- sending logs to external services.+module Reporter.File+  ( Internal.report,+    Internal.Handler,+    Internal.handler,+    Internal.cleanup,+    Internal.Settings,+    Internal.decoder,+    Internal.logFile,+    Internal.appName,+    Internal.appEnvironment,+    Internal.fractionOfSuccessRequestsLogged,+  )+where++import qualified Reporter.File.Internal as Internal
+ src/Reporter/File/Internal.hs view
@@ -0,0 +1,222 @@+module Reporter.File.Internal where++import qualified Control.Concurrent.Async as Async+import qualified Control.Concurrent.STM as STM+import qualified Data.Aeson as Aeson+import qualified Data.Aeson.Encoding+import qualified Data.ByteString.Lazy as ByteString+import qualified Data.Foldable+import qualified Environment+import qualified Network.HostName+import qualified Platform+import qualified Platform.ReporterHelpers as Helpers+import qualified Platform.Timer as Timer+import qualified System.IO+import qualified System.Random as Random+import qualified Prelude++-- Log tracing information for a request to a file. Tracing information contains+-- nested spans but will appear flattend in the log. Each tracing span will+-- appear on its own line in the log, ordered by its start date.+--+-- Example usage:+--+-- > settings <- File.decode+-- > handler <- File.handler settings+-- > File.report handler "request-id" span+report :: Handler -> Text -> Platform.TracingSpan -> Prelude.IO ()+report Handler {skipLogging, writeQueue, logContext} requestId span = do+  skip <- skipLogging span+  if skip+    then Prelude.pure ()+    else+      logItemRecursively logContext {requestId} span []+        |> STM.writeTBQueue writeQueue+        |> (`STM.orElse` Prelude.pure ()) -- Drop the log if the queue is full.+        |> STM.atomically++logItemRecursively ::+  LogContext ->+  Platform.TracingSpan ->+  [ByteString.ByteString] ->+  [ByteString.ByteString]+logItemRecursively logContext span acc =+  logItemRecursivelyHelper logContext span acc+    |> List.reverse++logItemRecursivelyHelper ::+  LogContext ->+  Platform.TracingSpan ->+  [ByteString.ByteString] ->+  [ByteString.ByteString]+logItemRecursivelyHelper logContext span acc =+  List.foldr -- The right-most child is the oldest, so that's where we start+    ( logItemRecursivelyHelper+        logContext+          { namespace = Platform.name span : namespace logContext+          }+    )+    (logItem logContext span : acc)+    (Platform.children span)++logItem :: LogContext -> Platform.TracingSpan -> ByteString.ByteString+logItem LogContext {timer, namespace, environment, requestId, hostname} span =+  (Aeson..=) "name" (Platform.name span)+    ++ (Aeson..=) "start_utc" (Timer.toUTC timer (Platform.started span))+    ++ (Aeson..=) "duration_ms" (Prelude.fromIntegral (Timer.durationInUs span) / 1000)+    ++ (Aeson..=) "env" environment+    ++ (Aeson..=) "namespace" namespace+    ++ (Aeson..=) "request-id" requestId+    ++ (Aeson..=) "hostname" hostname+    ++ ( case Platform.frame span of+           Nothing -> Prelude.mempty+           Just (_, srcLoc) ->+             (Aeson..=) "src" (Helpers.srcString srcLoc)+       )+    ++ (Aeson..=) "allocated_mb" (toFloat (Platform.allocated span) / 1e6)+    ++ (Aeson..=) "details" (Platform.details span)+      |> Aeson.pairs+      |> Data.Aeson.Encoding.encodingToLazyByteString++data Handler = Handler+  { fileHandle :: System.IO.Handle,+    logContext :: LogContext,+    skipLogging :: Platform.TracingSpan -> Prelude.IO Bool,+    writeQueue :: STM.TBQueue [ByteString.ByteString],+    loggingThread :: Async.Async ()+  }++data LogContext = LogContext+  { timer :: Timer.Timer,+    namespace :: [Text],+    environment :: Text,+    requestId :: Text,+    hostname :: Text+  }++-- | Create a 'Handler' for a specified set of 'Settings'. Do this once when+-- your application starts and reuse the 'Handler' you get.+handler :: Settings -> Prelude.IO Handler+handler settings = do+  let skipLogging span =+        case Platform.succeeded span of+          Platform.Succeeded -> do+            roll <- Random.randomRIO (0.0, 1.0)+            Prelude.pure (roll > fractionOfSuccessRequestsLogged settings)+          Platform.Failed -> Prelude.pure False+          Platform.FailedWith _ -> Prelude.pure False+  fileHandle <- System.IO.openFile (logFile settings) System.IO.AppendMode+  hostname <- map Text.fromList Network.HostName.getHostName+  writeQueue <- STM.atomically (STM.newTBQueue 100)+  loggingThread <- Async.async (logLoop writeQueue fileHandle)+  timer <- Timer.mkTimer+  let logContext =+        LogContext+          { timer,+            hostname,+            requestId = "", -- This changes per request and so is set later.+            namespace = [appName settings],+            environment = appEnvironment settings+          }+  Prelude.pure+    Handler+      { fileHandle,+        skipLogging,+        writeQueue,+        loggingThread,+        logContext+      }++logLoop ::+  STM.TBQueue [ByteString.ByteString] ->+  System.IO.Handle ->+  Prelude.IO ()+logLoop writeQueue fileHandle = do+  lines <- STM.atomically (STM.readTBQueue writeQueue)+  Data.Foldable.for_ lines <| \line -> do+    ByteString.hPut fileHandle line+    ByteString.hPut fileHandle "\n"+  logLoop writeQueue fileHandle++cleanup :: Handler -> Prelude.IO ()+cleanup Handler {loggingThread, fileHandle} = do+  Async.cancel loggingThread+  System.IO.hClose fileHandle++-- | Configuration settings for this reporter. A value of this type can be read+-- from the environment using the 'decoder' function.+data Settings = Settings+  { -- | The file to log too. On unix systems you can set this to @/dev/stdout@+    -- in order to log to stdout.+    --+    -- [@environment variable@] LOG_FILE+    -- [@default value@] app.log+    logFile :: Prelude.FilePath,+    -- | The name of this application. This will be attached to all logs.+    --+    -- [@environment variable@] LOG_ROOT_NAMESPACE+    -- [@default value@] your-application-name-here+    appName :: Text,+    -- | The environment this application is running in. This will be attached+    -- to all logs.+    --+    -- [@environment variable@] ENVIRONMENT+    -- [@default value@] development+    appEnvironment :: Text,+    -- | The fraction of requests that should be logged. Set to 1 if you want to+    -- log everything, and a lower value to save money.+    --+    -- [@environment variable@] FRACTION_OF_SUCCESS_REQUESTS_LOGGED+    -- [@default value@] 1+    fractionOfSuccessRequestsLogged :: Float+  }++-- | Read 'Settings' from environment variables. Default variables will be used+-- in case no environment variable is set for an option.+decoder :: Environment.Decoder Settings+decoder =+  Prelude.pure Settings+    |> andMap logFileDecoder+    |> andMap appNameDecoder+    |> andMap environmentDecoder+    |> andMap fractionOfSuccessRequestsLoggedDecoder++logFileDecoder :: Environment.Decoder Prelude.FilePath+logFileDecoder =+  Environment.variable+    Environment.Variable+      { Environment.name = "LOG_FILE",+        Environment.description = "File to log too.",+        Environment.defaultValue = "app.log"+      }+    Environment.filePath++appNameDecoder :: Environment.Decoder Text+appNameDecoder =+  Environment.variable+    Environment.Variable+      { Environment.name = "LOG_ROOT_NAMESPACE",+        Environment.description = "Root of the log namespace. This should be the name of the application.",+        Environment.defaultValue = "your-application-name-here"+      }+    Environment.text++environmentDecoder :: Environment.Decoder Text+environmentDecoder =+  Environment.variable+    Environment.Variable+      { Environment.name = "ENVIRONMENT",+        Environment.description = "Environment to display in logs.",+        Environment.defaultValue = "development"+      }+    Environment.text++fractionOfSuccessRequestsLoggedDecoder :: Environment.Decoder Float+fractionOfSuccessRequestsLoggedDecoder =+  Environment.variable+    Environment.Variable+      { Environment.name = "FRACTION_OF_SUCCESS_REQUESTS_LOGGED",+        Environment.description = "The fraction of successful requests logged. Defaults to logging all successful requests.",+        Environment.defaultValue = "1"+      }+    Environment.float
+ tests/Main.hs view
@@ -0,0 +1,21 @@+module Main (main) where++import qualified Spec.Platform.Timer+import qualified Spec.Reporter.Bugsnag+import qualified Spec.Reporter.Dev+import qualified Spec.Reporter.File+import qualified Test+import qualified Prelude++main :: Prelude.IO ()+main = Test.run tests++tests :: Test.Test+tests =+  Test.describe+    "nri-observability"+    [ Spec.Platform.Timer.tests,+      Spec.Reporter.Bugsnag.tests,+      Spec.Reporter.Dev.tests,+      Spec.Reporter.File.tests+    ]
+ tests/Spec/Platform/Timer.hs view
@@ -0,0 +1,27 @@+module Spec.Platform.Timer (tests) where++import qualified Data.Time.Clock.POSIX as Clock.POSIX+import qualified Expect+import qualified GHC.Clock+import qualified Platform.Timer as Timer+import qualified Test+import qualified Prelude++tests :: Test.Test+tests =+  Test.describe+    "Observability.Timer"+    [ Test.test "can reproduce the current time" <| \_ -> do+        timer <- Expect.fromIO <| Timer.mkTimer+        clock <-+          GHC.Clock.getMonotonicTimeNSec+            |> map (\t -> Prelude.fromIntegral (t `Prelude.div` 1000))+            |> Expect.fromIO+        expectedNow <- Expect.fromIO <| Clock.POSIX.getPOSIXTime+        let actualNow = Timer.toPosix timer clock+        actualNow+          |> Expect.all+            [ Expect.atLeast (expectedNow - 1),+              Expect.atMost (expectedNow + 1)+            ]+    ]
+ tests/Spec/Reporter/Bugsnag.hs view
@@ -0,0 +1,444 @@+module Spec.Reporter.Bugsnag (tests) where++import qualified Control.Exception.Safe as Exception+import qualified Data.Aeson as Aeson+import qualified Data.Aeson.Encode.Pretty+import qualified Data.ByteString.Lazy+import qualified Data.Text.Encoding+import qualified Data.Time.LocalTime as LocalTime+import qualified Dict+import qualified Expect+import qualified GHC.Stack as Stack+import qualified Log+import qualified Log.HttpRequest as HttpRequest+import qualified Log.RedisCommands as RedisCommands+import qualified Log.SqlQuery as SqlQuery+import qualified Network.Bugsnag+import qualified Platform+import qualified Platform.Timer as Timer+import qualified Reporter.Bugsnag.Internal as Bugsnag+import Test (Test, describe, test)+import qualified Prelude++tests :: Test+tests =+  describe+    "Observability.Bugsnag"+    [ test "encodes span marked as failed as an exception" <| \_ ->+        emptyTracingSpan+          { Platform.name = "root span",+            Platform.succeeded = Platform.Failed+          }+          |> encodesTo "bugsnag-failure-without-exception",+      test "if an exception was thrown use that exceptions name" <| \_ ->+        emptyTracingSpan+          { Platform.succeeded =+              Platform.FailedWith (Exception.SomeException (CustomException "something went wrong"))+          }+          |> encodesTo "bugsnag-failure-with-exception",+      test "identifies the most recently started failing span as the root cause failure" <| \_ ->+        emptyTracingSpan+          { Platform.name = "parent of root cause",+            Platform.succeeded = Platform.Failed,+            Platform.children =+              [ emptyTracingSpan+                  { Platform.name = "after root cause",+                    Platform.succeeded = Platform.Succeeded+                  },+                emptyTracingSpan+                  { Platform.name = "root cause",+                    Platform.succeeded = Platform.Failed,+                    Platform.children =+                      [ emptyTracingSpan+                          { Platform.name = "child of root cause",+                            Platform.succeeded = Platform.Succeeded+                          }+                      ]+                  },+                emptyTracingSpan+                  { Platform.name = "before root cause",+                    Platform.succeeded = Platform.Succeeded+                  }+              ]+          }+          |> encodesTo "bugsnag-failure-with-root-cause",+      test "shows stack trace from the root span to the root cause span" <| \_ ->+        emptyTracingSpan+          { Platform.succeeded = Platform.Failed,+            Platform.frame = Just ("function1", Stack.SrcLoc "package1" "Module2" "Filename1.hs" 0 1 2 3),+            Platform.children =+              [ emptyTracingSpan+                  { Platform.succeeded = Platform.Succeeded,+                    Platform.frame = Just ("function2", Stack.SrcLoc "package2" "Module2" "Filename2.hs" 10 11 12 13)+                  },+                emptyTracingSpan+                  { Platform.succeeded = Platform.Failed,+                    Platform.frame = Just ("function3", Stack.SrcLoc "package3" "Module3" "Filename3.hs" 20 21 22 23),+                    Platform.children =+                      [ emptyTracingSpan+                          { Platform.succeeded = Platform.Succeeded,+                            Platform.frame = Just ("function4", Stack.SrcLoc "package4" "Module4" "Filename4.hs" 30 31 32 33)+                          }+                      ]+                  },+                emptyTracingSpan+                  { Platform.succeeded = Platform.Succeeded,+                    Platform.frame = Just ("function5", Stack.SrcLoc "package5" "Module5" "Filename5.hs" 40 41 42 43)+                  }+              ]+          }+          |> encodesTo "bugsnag-failure-stack-trace",+      test "encodes information about an incoming http request" <| \_ ->+        emptyTracingSpan+          { Platform.succeeded = Platform.Failed,+            Platform.details =+              HttpRequest.emptyDetails+                { HttpRequest.httpVersion = Just "1",+                  HttpRequest.method = Just "GET",+                  HttpRequest.path = Just "/hats/5",+                  HttpRequest.queryString = Just "?top=flat",+                  HttpRequest.endpoint = Just "GET /hats/:hat_id",+                  HttpRequest.headers = Dict.fromList [("Accept", "application/json")],+                  HttpRequest.status = Just 500+                }+                |> HttpRequest.Incoming+                |> Platform.toTracingSpanDetails+                |> Just+          }+          |> encodesTo "bugsnag-failure-incoming-http-request",+      test "renders sql query failures correctly" <| \_ ->+        emptyTracingSpan+          { Platform.succeeded = Platform.Failed,+            Platform.details =+              SqlQuery.emptyDetails+                { SqlQuery.query = Just (Log.mkSecret "SELECT name FROM ants WHERE id = 3"),+                  SqlQuery.databaseType = Just SqlQuery.postgresql,+                  SqlQuery.queryTemplate = Just "SELECT name FROM ants WHERE id = ${id}",+                  SqlQuery.sqlOperation = Just "SELECT",+                  SqlQuery.queriedRelation = Just "ants",+                  SqlQuery.host = Just "http://antsonline.com",+                  SqlQuery.port = Just 5432,+                  SqlQuery.database = Just "nri",+                  SqlQuery.rowsReturned = Just 5+                }+                |> Platform.toTracingSpanDetails+                |> Just+          }+          |> encodesTo "bugsnag-failure-sql",+      test "renders redis query failures correctly" <| \_ ->+        emptyTracingSpan+          { Platform.succeeded = Platform.Failed,+            Platform.details =+              RedisCommands.emptyDetails+                { RedisCommands.host = Just "cache.noredink.com",+                  RedisCommands.port = Just 6379,+                  RedisCommands.commands = ["GET somekey"]+                }+                |> Platform.toTracingSpanDetails+                |> Just+          }+          |> encodesTo "bugsnag-failure-redis",+      test "renders outgoing http request failures correctly" <| \_ ->+        emptyTracingSpan+          { Platform.succeeded = Platform.Failed,+            Platform.children =+              [ emptyTracingSpan+                  { Platform.succeeded = Platform.Failed,+                    Platform.details =+                      HttpRequest.emptyDetails+                        { HttpRequest.host = Just "http://antsonline.com",+                          HttpRequest.path = Just "/bulletin",+                          HttpRequest.method = Just "GET"+                        }+                        |> HttpRequest.Outgoing+                        |> Platform.toTracingSpanDetails+                        |> Just+                  }+              ]+          }+          |> encodesTo "bugsnag-failure-http",+      test "renders log failures correctly" <| \_ ->+        emptyTracingSpan+          { Platform.succeeded = Platform.Failed,+            Platform.name = "log message",+            Platform.details =+              Log.LogContexts+                [ Log.context "number" (4 :: Int),+                  Log.context "text" ("Hi!" :: Text)+                ]+                |> Platform.toTracingSpanDetails+                |> Just+          }+          |> encodesTo "bugsnag-failure-log",+      test "renders withContext failures correctly" <| \_ ->+        emptyTracingSpan+          { Platform.succeeded = Platform.Failed,+            Platform.name = "some context",+            Platform.details =+              Log.LogContexts+                [ Log.context "number" (4 :: Int)+                ]+                |> Platform.toTracingSpanDetails+                |> Just,+            Platform.children =+              [ emptyTracingSpan+                  { Platform.succeeded = Platform.Succeeded+                  }+              ]+          }+          |> encodesTo "bugsnag-failure-with-context",+      test "merges unknown span details from nested spans into metadata" <| \_ ->+        emptyTracingSpan+          { Platform.name = "measure weather",+            Platform.succeeded = Platform.Failed,+            Platform.details =+              CustomDetails "32 C"+                |> Platform.toTracingSpanDetails+                |> Just,+            Platform.children =+              [ emptyTracingSpan+                  { Platform.name = "measure humidity",+                    Platform.succeeded = Platform.Failed,+                    Platform.details =+                      CustomDetails "25 %"+                        |> Platform.toTracingSpanDetails+                        |> Just+                  }+              ]+          }+          |> encodesTo "bugsnag-failure-unknown",+      test "non-failing spans are encoded as breadcrumbs in the right order" <| \_ ->+        emptyTracingSpan+          { Platform.name = "root-span (failed)",+            Platform.succeeded = Platform.Failed,+            Platform.children =+              [ emptyTracingSpan+                  { Platform.name = "child-3 (leaf)",+                    Platform.succeeded = Platform.Succeeded+                  },+                emptyTracingSpan+                  { Platform.name = "child-2 (failed)",+                    Platform.succeeded = Platform.Failed,+                    Platform.children =+                      [ emptyTracingSpan+                          { Platform.name = "child-3-grandchild-3 (failed, root cause)",+                            Platform.succeeded = Platform.Failed,+                            Platform.children =+                              [ emptyTracingSpan+                                  { Platform.name = "child-3-grandchild-3-greatgrandchild-2 (leaf)",+                                    Platform.succeeded = Platform.Succeeded+                                  },+                                emptyTracingSpan+                                  { Platform.name = "child-3-grandchild-3-greatgrandchild-1 (leaf)",+                                    Platform.succeeded = Platform.Succeeded+                                  }+                              ]+                          },+                        emptyTracingSpan+                          { Platform.name = "child-2-grandchild-2",+                            Platform.succeeded = Platform.Succeeded,+                            Platform.children =+                              [ emptyTracingSpan+                                  { Platform.name = "child-2-grandchild-2-greatgrandchild-2 (leaf)",+                                    Platform.succeeded = Platform.Failed+                                  },+                                emptyTracingSpan+                                  { Platform.name = "child-2-grandchild-2-greatgrandchild-1 (leaf)",+                                    Platform.succeeded = Platform.Succeeded+                                  }+                              ]+                          },+                        emptyTracingSpan+                          { Platform.name = "child-2-grandchild-1 (leaf)",+                            Platform.succeeded = Platform.Succeeded+                          }+                      ]+                  },+                emptyTracingSpan+                  { Platform.name = "child-1 (leaf)",+                    Platform.succeeded = Platform.Succeeded+                  }+              ]+          }+          |> encodesTo "bugsnag-breadcrumbs-right-order",+      test "renders sql query breadcrumbs correctly" <| \_ ->+        emptyTracingSpan+          { Platform.succeeded = Platform.Failed,+            Platform.children =+              [ emptyTracingSpan+                  { Platform.succeeded = Platform.Succeeded,+                    Platform.details =+                      SqlQuery.emptyDetails+                        { SqlQuery.query = Just (Log.mkSecret "SELECT name FROM ants WHERE id = 3"),+                          SqlQuery.databaseType = Just SqlQuery.postgresql,+                          SqlQuery.queryTemplate = Just "SELECT name FROM ants WHERE id = ${id}",+                          SqlQuery.sqlOperation = Just "SELECT",+                          SqlQuery.queriedRelation = Just "ants",+                          SqlQuery.host = Just "http://antsonline.com",+                          SqlQuery.port = Just 5432,+                          SqlQuery.database = Just "nri",+                          SqlQuery.rowsReturned = Just 5+                        }+                        |> Platform.toTracingSpanDetails+                        |> Just+                  }+              ]+          }+          |> encodesTo "bugsnag-breadcrumbs-sql",+      test "renders redis query breadcrumbs correctly" <| \_ ->+        emptyTracingSpan+          { Platform.succeeded = Platform.Failed,+            Platform.children =+              [ emptyTracingSpan+                  { Platform.succeeded = Platform.Succeeded,+                    Platform.details =+                      RedisCommands.emptyDetails+                        { RedisCommands.host = Just "cache.noredink.com",+                          RedisCommands.port = Just 6379,+                          RedisCommands.commands = ["GET somekey"]+                        }+                        |> Platform.toTracingSpanDetails+                        |> Just+                  }+              ]+          }+          |> encodesTo "bugsnag-breadcrumbs-redis",+      test "renders outgoing http request breadcrumbs correctly" <| \_ ->+        emptyTracingSpan+          { Platform.succeeded = Platform.Failed,+            Platform.children =+              [ emptyTracingSpan+                  { Platform.succeeded = Platform.Succeeded,+                    Platform.children =+                      [ emptyTracingSpan+                          { Platform.succeeded = Platform.Succeeded,+                            Platform.details =+                              HttpRequest.emptyDetails+                                { HttpRequest.host = Just "http://antsonline.com",+                                  HttpRequest.path = Just "/bulletin",+                                  HttpRequest.method = Just "GET"+                                }+                                |> HttpRequest.Outgoing+                                |> Platform.toTracingSpanDetails+                                |> Just+                          }+                      ]+                  }+              ]+          }+          |> encodesTo "bugsnag-breadcrumbs-http",+      test "renders log breadcrumbs correctly" <| \_ ->+        emptyTracingSpan+          { Platform.succeeded = Platform.Failed,+            Platform.children =+              [ emptyTracingSpan+                  { Platform.succeeded = Platform.Succeeded,+                    Platform.details =+                      Log.LogContexts+                        [ Log.context "number" (4 :: Int),+                          Log.context "text" ("Hi!" :: Text)+                        ]+                        |> Platform.toTracingSpanDetails+                        |> Just+                  }+              ]+          }+          |> encodesTo "bugsnag-breadcrumbs-log",+      test "renders withContext breadcrumbs correctly" <| \_ ->+        emptyTracingSpan+          { Platform.succeeded = Platform.Failed,+            Platform.children =+              [ emptyTracingSpan+                  { Platform.succeeded = Platform.Succeeded,+                    Platform.details =+                      Log.LogContexts+                        [ Log.context "number" (4 :: Int)+                        ]+                        |> Platform.toTracingSpanDetails+                        |> Just,+                    Platform.children =+                      [ emptyTracingSpan+                          { Platform.succeeded = Platform.Succeeded+                          }+                      ]+                  }+              ]+          }+          |> encodesTo "bugsnag-breadcrumbs-with-context",+      test "renders unknown spans breadcrumbs correctly" <| \_ ->+        emptyTracingSpan+          { Platform.succeeded = Platform.Failed,+            Platform.children =+              [ emptyTracingSpan+                  { Platform.succeeded = Platform.Succeeded,+                    Platform.details =+                      CustomDetails "The deets!"+                        |> Platform.toTracingSpanDetails+                        |> Just+                  }+              ]+          }+          |> encodesTo "bugsnag-breadcrumbs-unknown",+      test "renders timestamps correctly" <| \_ ->+        emptyTracingSpan+          { Platform.succeeded = Platform.Failed,+            Platform.started = ms 4,+            Platform.finished = ms 9, -- 8 ms+            Platform.children =+              [ emptyTracingSpan+                  { Platform.succeeded = Platform.Succeeded,+                    Platform.name = "outer",+                    Platform.started = ms 5,+                    Platform.finished = ms 8, -- 8 ms+                    Platform.children =+                      [ emptyTracingSpan+                          { Platform.succeeded = Platform.Succeeded,+                            Platform.name = "inner",+                            Platform.started = ms 6, -- 6 ms+                            Platform.finished = ms 7, -- 7 ms+                            Platform.children =+                              []+                          }+                      ]+                  }+              ]+          }+          |> encodesTo "bugsnag-breadcrumbs-timestamps"+    ]++newtype CustomException = CustomException Text deriving (Show)++instance Exception.Exception CustomException++newtype CustomDetails = CustomDetails Text+  deriving (Aeson.ToJSON)++instance Platform.TracingSpanDetails CustomDetails++ms :: Int -> Platform.MonotonicTime+ms n = Prelude.fromIntegral (n * 1000)++timer :: Timer.Timer+timer = Timer.Timer 0 LocalTime.utc++emptyTracingSpan :: Platform.TracingSpan+emptyTracingSpan =+  Platform.emptyTracingSpan+    { Platform.name = "some span",+      Platform.started = 0,+      Platform.finished = 0,+      Platform.frame = Nothing,+      Platform.details = Nothing,+      Platform.succeeded = Platform.Succeeded,+      Platform.allocated = 0,+      Platform.children = []+    }++encodesTo :: Text -> Platform.TracingSpan -> Expect.Expectation+encodesTo filename span =+  span+    |> Bugsnag.toEvent "request-id-1234" timer Network.Bugsnag.defaultEvent+    |> Data.Aeson.Encode.Pretty.encodePretty+    |> Data.ByteString.Lazy.toStrict+    |> Data.Text.Encoding.decodeUtf8+    |> Expect.equalToContentsOf ("tests/golden-results/" ++ filename ++ ".json")
+ tests/Spec/Reporter/Dev.hs view
@@ -0,0 +1,76 @@+module Spec.Reporter.Dev (tests) where++import qualified Control.Exception.Safe as Exception+import qualified Data.Text.Lazy+import qualified Data.Text.Lazy.Builder+import qualified Data.Time.LocalTime as LocalTime+import qualified Expect+import qualified Log.HttpRequest as HttpRequest+import qualified Platform+import qualified Platform.Timer as Timer+import qualified Reporter.Dev.Internal as Dev+import qualified Test+import qualified Prelude++tests :: Test.Test+tests =+  Test.describe+    "Observability.Dev"+    [ logTest+        "log span without details or children"+        emptyTracingSpan+          { Platform.name = "root-span",+            Platform.started = ms 5+          },+      logTest+        "log span with exception"+        emptyTracingSpan+          { Platform.name = "root-span",+            Platform.succeeded = Platform.FailedWith (Exception.SomeException (CustomException "toast!"))+          },+      logTest+        "logs information about an incoming http request"+        emptyTracingSpan+          { Platform.details =+              HttpRequest.emptyDetails+                { HttpRequest.endpoint = Just "/hats/:hat_id"+                }+                |> HttpRequest.Incoming+                |> Platform.toTracingSpanDetails+                |> Just+          }+    ]++logTest :: Text -> Platform.TracingSpan -> Test.Test+logTest name span =+  Test.test name <| \_ -> do+    let logfile = Text.replace " " "-" name+    let logpath = "tests/golden-results/dev-reporter-" ++ logfile+    span+      |> Dev.mkLog timer+      |> Data.Text.Lazy.Builder.toLazyText+      |> Data.Text.Lazy.toStrict+      |> Expect.equalToContentsOf logpath++newtype CustomException = CustomException Text deriving (Show)++instance Exception.Exception CustomException++ms :: Int -> Platform.MonotonicTime+ms n = Prelude.fromIntegral (n * 1000)++timer :: Timer.Timer+timer = Timer.Timer 0 LocalTime.utc++emptyTracingSpan :: Platform.TracingSpan+emptyTracingSpan =+  Platform.emptyTracingSpan+    { Platform.name = "Example TracingSpan",+      Platform.started = 0,+      Platform.finished = 0,+      Platform.frame = Nothing,+      Platform.details = Nothing,+      Platform.succeeded = Platform.Succeeded,+      Platform.allocated = 0,+      Platform.children = []+    }
+ tests/Spec/Reporter/File.hs view
@@ -0,0 +1,186 @@+module Spec.Reporter.File (tests) where++import qualified Control.Exception.Safe as Exception+import qualified Data.Aeson as Aeson+import qualified Data.Aeson.Encode.Pretty+import qualified Data.ByteString.Lazy+import qualified Data.Text.Lazy+import qualified Data.Text.Lazy.Encoding+import qualified Data.Time.LocalTime as LocalTime+import qualified Dict+import qualified Expect+import qualified Log.HttpRequest as HttpRequest+import qualified Log.RedisCommands as RedisCommands+import qualified Log.SqlQuery as SqlQuery+import qualified Platform+import qualified Platform.Timer as Timer+import qualified Reporter.File.Internal as File+import qualified Test+import qualified Prelude++tests :: Test.Test+tests =+  Test.describe+    "Observability.FileSpec"+    [ logTest+        "logs span without details or children"+        emptyTracingSpan+          { Platform.name = "root-span",+            Platform.finished = ms 7+          },+      logTest+        "logs span that failed with an exception"+        emptyTracingSpan+          { Platform.name = "root-span",+            Platform.succeeded = Platform.FailedWith (Exception.SomeException (CustomException "help!"))+          },+      logTest+        "logs nested spans"+        emptyTracingSpan+          { Platform.name = "root-span",+            Platform.children =+              [ emptyTracingSpan+                  { Platform.name = "child1",+                    Platform.children =+                      [ emptyTracingSpan+                          { Platform.name = "grandchild",+                            Platform.children = []+                          }+                      ]+                  },+                emptyTracingSpan+                  { Platform.name = "child2",+                    Platform.children = []+                  }+              ]+          },+      logTest+        "logs information about an incoming http request"+        emptyTracingSpan+          { Platform.details =+              HttpRequest.emptyDetails+                { HttpRequest.httpVersion = Just "1",+                  HttpRequest.method = Just "GET",+                  HttpRequest.path = Just "/hats/5",+                  HttpRequest.queryString = Just "?top=flat",+                  HttpRequest.endpoint = Just "/hats/:hat_id",+                  HttpRequest.headers = Dict.fromList [("Accept", "application/json")],+                  HttpRequest.status = Just 500+                }+                |> HttpRequest.Incoming+                |> Platform.toTracingSpanDetails+                |> Just+          },+      logTest+        "logs information about an SQL query"+        emptyTracingSpan+          { Platform.details =+              SqlQuery.emptyDetails+                { SqlQuery.databaseType = Just SqlQuery.postgresql,+                  SqlQuery.query = Just (Log.mkSecret "SELECT name FROM ants WHERE id = ?"),+                  SqlQuery.queryTemplate = Just "SELECT name FROM ants WHERE id = ${id}",+                  SqlQuery.sqlOperation = Just "SELECT",+                  SqlQuery.queriedRelation = Just "ants",+                  SqlQuery.host = Just "http://antsonline.com",+                  SqlQuery.port = Just 5432,+                  SqlQuery.database = Just "nri",+                  SqlQuery.rowsReturned = Just 5+                }+                |> Platform.toTracingSpanDetails+                |> Just+          },+      logTest+        "logs information about a redis query"+        emptyTracingSpan+          { Platform.details =+              RedisCommands.emptyDetails+                { RedisCommands.host = Just "cache.noredink.com",+                  RedisCommands.port = Just 6379,+                  RedisCommands.commands = ["GET somekey"]+                }+                |> Platform.toTracingSpanDetails+                |> Just+          },+      logTest+        "logs information about an outoing http request"+        emptyTracingSpan+          { Platform.details =+              HttpRequest.emptyDetails+                { HttpRequest.httpVersion = Just "1",+                  HttpRequest.method = Just "GET",+                  HttpRequest.path = Just "/hats/5",+                  HttpRequest.queryString = Just "?top=flat",+                  HttpRequest.endpoint = Just "/hats/:hat_id",+                  HttpRequest.headers = Dict.fromList [("Accept", "application/json")],+                  HttpRequest.status = Just 500+                }+                |> HttpRequest.Incoming+                |> Platform.toTracingSpanDetails+                |> Just+          },+      logTest+        "logs log messages"+        emptyTracingSpan+          { Platform.details =+              Log.LogContexts+                [ Log.context "number" (4 :: Int),+                  Log.context "text" ("Hi!" :: Text)+                ]+                |> Platform.toTracingSpanDetails+                |> Just+          }+    ]++logTest :: Text -> Platform.TracingSpan -> Test.Test+logTest name span =+  Test.test name <| \_ -> do+    let logContext =+          File.LogContext+            { File.requestId = "request-id",+              File.namespace = ["app"],+              File.environment = "narnia",+              File.hostname = "some-host",+              File.timer = Timer.Timer 0 LocalTime.utc+            }+    let reEncodingResult =+          File.logItemRecursively logContext span []+            -- Pretty-print each log entry to make test files more readable.+            |> Prelude.traverse reEncodePretty+    case reEncodingResult of+      Err err -> Expect.fail err+      Ok reEncoded ->+        Text.join "\n" reEncoded+          |> Expect.equalToContentsOf+            ( Text.fromList "tests/golden-results/file-reporter-"+                ++ Text.replace " " "-" name+            )++reEncodePretty :: Data.ByteString.Lazy.ByteString -> Result Text Text+reEncodePretty str =+  case Aeson.eitherDecode' str of+    Prelude.Left err -> Err (Text.fromList err)+    Prelude.Right (json :: Aeson.Value) ->+      Data.Aeson.Encode.Pretty.encodePretty json+        |> Data.Text.Lazy.Encoding.decodeUtf8+        |> Data.Text.Lazy.toStrict+        |> Ok++newtype CustomException = CustomException Text deriving (Show)++instance Exception.Exception CustomException++ms :: Int -> Platform.MonotonicTime+ms n = Prelude.fromIntegral (1000 * n)++emptyTracingSpan :: Platform.TracingSpan+emptyTracingSpan =+  Platform.emptyTracingSpan+    { Platform.name = "",+      Platform.started = 0,+      Platform.finished = 0,+      Platform.frame = Nothing,+      Platform.details = Nothing,+      Platform.succeeded = Platform.Succeeded,+      Platform.allocated = 0,+      Platform.children = []+    }