packages feed

nri-observability 0.1.1.0 → 0.1.1.1

raw patch · 6 files changed

+366/−3 lines, 6 filesdep +conduitPVP: minor bump suggested

API additions: PVP suggests at least a minor version bump

Dependencies added: conduit

API changes (from Hackage documentation)

+ Observability: Settings :: [Reporter] -> Settings -> Settings -> Settings -> () -> Settings
+ Observability: [Reporter] :: {reporterName :: Text, reporterSettings :: Settings -> settings " Pick the reporter-specific settings from the global `Settings` type defined in this module.", reporterHandler :: settings -> Acquire handler " Create a handler for this reporter. This function will be called once when the application starts for each enabled reporter.", reporterReport :: handler -> Text -> TracingSpan -> IO () " Report a span to this reporter."} -> Reporter
+ Observability: [bugsnag] :: Settings -> Settings
+ Observability: [dev] :: Settings -> ()
+ Observability: [enabledReporters] :: Settings -> [Reporter]
+ Observability: [file] :: Settings -> Settings
+ Observability: [honeycomb] :: Settings -> Settings
+ Observability: data Handler
+ Observability: data Reporter
+ Observability: data Settings
+ Observability: decoder :: Decoder Settings
+ Observability: handler :: Settings -> Acquire Handler
+ Observability: instance Data.Aeson.Types.ToJSON.ToJSON Observability.FailedToReportTracingSpan
+ Observability: instance GHC.Base.Monoid Observability.Handler
+ Observability: instance GHC.Base.Semigroup Observability.Handler
+ Observability: instance GHC.Generics.Generic Observability.FailedToReportTracingSpan
+ Observability: instance Platform.Internal.TracingSpanDetails Observability.FailedToReportTracingSpan
+ Observability: report :: Handler -> Text -> TracingSpan -> IO ()

Files

CHANGELOG.md view
@@ -1,3 +1,8 @@+# 0.1.1.1++- Add `Observability` glue module (still needs better documentation!)+  - Adds direct dependency on conduit+ # 0.1.1.0  - Add honeycomb.io reporter.
README.md view
@@ -6,7 +6,8 @@  - `Reporter` modules for sending data to various backends. - `Log` modules defining `Details` types for common kinds of tracing spans.+- `Observability` module for a unified way of reporting to multiple targets. -Both can be mixed and matched with `Reporter` and `Details` types defined elsewhere.+`Reporter` and `Log` 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
@@ -5,7 +5,7 @@ -- see: https://github.com/sol/hpack  name:           nri-observability-version:        0.1.1.0+version:        0.1.1.1 synopsis:       Report log spans collected by nri-prelude. description:    Please see the README at <https://github.com/NoRedInk/haskell-libraries/tree/trunk/nri-observability#readme>. category:       Web@@ -37,6 +37,7 @@       Reporter.Dev       Reporter.File       Reporter.Honeycomb+      Observability   other-modules:       Platform.ReporterHelpers       Platform.Timer@@ -69,6 +70,7 @@     , base >=4.12.0.0 && <4.16     , bugsnag-hs >=0.1.0.0 && <0.3     , bytestring >=0.10.8.2 && <0.12+    , conduit >=1.3.0 && <1.4     , directory >=1.3.3.0 && <1.4     , hostname ==1.0.*     , http-client >=0.6.0 && <0.8@@ -88,6 +90,7 @@   type: exitcode-stdio-1.0   main-is: Main.hs   other-modules:+      Spec.Observability       Spec.Platform.Timer       Spec.Reporter.Bugsnag       Spec.Reporter.Dev@@ -97,6 +100,7 @@       Log.Kafka       Log.RedisCommands       Log.SqlQuery+      Observability       Platform.ReporterHelpers       Platform.Timer       Reporter.Bugsnag@@ -134,6 +138,7 @@     , base >=4.12.0.0 && <4.16     , bugsnag-hs >=0.1.0.0 && <0.3     , bytestring >=0.10.8.2 && <0.12+    , conduit >=1.3.0 && <1.4     , directory >=1.3.3.0 && <1.4     , hostname ==1.0.*     , http-client >=0.6.0 && <0.8
+ src/Observability.hs view
@@ -0,0 +1,239 @@+{-# LANGUAGE GADTs #-}++-- | A module dedicated to observability, that is reporting information about+-- what the program is doing in production to help us debugging it.+--+-- Specifically this module is dedicated to sending information that's already+-- been collected to external monitoring platforms, such as Bugsnag and+-- Honeycomb. To learn more about how this information is collected check out the+-- `Platform.Internal` module in the `nri-prelude` package. That module also+-- defines and documents the `TracingSpan` type, which is the data structure we use to+-- contain observability data we have collected.+module Observability+  ( report,+    Handler,+    Settings (..),+    Reporter (..),+    decoder,+    handler,+  )+where++import qualified Conduit+import qualified Control.Exception.Safe as Exception+import qualified Data.Aeson as Aeson+import qualified Environment+import qualified List+import qualified Platform+import qualified Reporter.Bugsnag as Bugsnag+import qualified Reporter.Dev as Dev+import qualified Reporter.File as File+import qualified Reporter.Honeycomb as Honeycomb+import qualified Set+import qualified Text+import Prelude (pure, traverse)+import qualified Prelude++-- | A handler for reporting to logging/monitoring/observability platforms.+--+-- The `report` function takes a span containing all the tracing information we+-- collected for a single request and then reports it to all platforms we+-- enabled when creating this handler.+newtype Handler = Handler {report :: Text -> Platform.TracingSpan -> Prelude.IO ()}+  deriving (Prelude.Semigroup, Prelude.Monoid)++-- | Function for creating an observability handler. The settings we pass in+-- determine which platforms we'll report information to.+handler :: Settings -> Conduit.Acquire Handler+handler settings = do+  case enabledReporters settings of+    [] -> Prelude.pure reportNothingHandler+    firstReporter : otherReporters -> do+      firstHandler <- toHandler reportNothingHandler settings firstReporter+      otherHandlers <- traverse (toHandler firstHandler settings) otherReporters+      Prelude.pure (Prelude.mconcat (firstHandler : otherHandlers))++reportNothingHandler :: Handler+reportNothingHandler = Handler (\_ _ -> Prelude.pure ())++-- | Initialize a reporter from settings, to get a handler for sending reports.+--+-- If a reporter fails with an exception here's what we'd like to be true:+-- - It doesn't affect other reporters (they don't get aborted or anything).+-- - The reporting error gets itself reported _somewhere_ in a way that's+--   relatively likely to succeed.+toHandler :: Handler -> Settings -> Reporter -> Conduit.Acquire Handler+toHandler backup settings Reporter {reporterName, reporterSettings, reporterHandler, reporterReport} = do+  handler' <-+    reporterSettings settings+      |> reporterHandler+      |> map (Handler << reporterReport)+  Prelude.pure+    ( Handler+        ( \requestId span ->+            report handler' requestId span+              -- If any error sync or async is thrown during reporting, report+              -- the failure to the 'backup reporter'.+              |> ( \io ->+                     Exception.withException+                       io+                       ( \err ->+                           err+                             |> reporterExceptionToTracingSpan reporterName span+                             |> report backup requestId+                       )+                 )+              -- If this reporter fails with a sync exception we do not want it+              -- to abort other reporters, so we catch those exceptions. We've+              -- already tried to report on the exception above so there's+              -- nothing more we could be doing with the exception anyway.+              |> Exception.handleAny (\_ -> Prelude.pure ())+        )+    )++-- | If a reporter fails with an exception we'd like to report that exception in+-- turn to a reporter. Because reporters take spans we turn the exception into a+-- span here.+reporterExceptionToTracingSpan :: Text -> Platform.TracingSpan -> Exception.SomeException -> Platform.TracingSpan+reporterExceptionToTracingSpan reporterName originalTracingSpan exceptionDuringReporting =+  Platform.emptyTracingSpan+    { Platform.name = "Failed to report span to " ++ reporterName,+      Platform.succeeded = Platform.FailedWith exceptionDuringReporting,+      Platform.started = Platform.finished originalTracingSpan,+      Platform.finished = Platform.finished originalTracingSpan,+      Platform.frame = Nothing,+      Platform.details =+        FailedToReportTracingSpan+          (Platform.name originalTracingSpan)+          (Platform.details originalTracingSpan)+          |> Platform.toTracingSpanDetails+          |> Just,+      Platform.allocated = 0,+      Platform.children = []+    }++-- | Some details about the span we were attempting to report when a reporting+-- failure happened. We're conservative with the information from the original+-- span we copy over here, in case some complexity somewhere in the original+-- span caused the report of it to fail. It's entirely likely the reporting+-- failure is unrelated to the reported span though. For example, reporting to+-- Bugsnag might fail if Bugsnag is down.+data FailedToReportTracingSpan = FailedToReportTracingSpan+  { originalTracingSpanName :: Text,+    originalTracingSpanDetails :: Maybe Platform.SomeTracingSpanDetails+  }+  deriving (Generic)++instance Aeson.ToJSON FailedToReportTracingSpan++instance Platform.TracingSpanDetails FailedToReportTracingSpan++-- | A helper type that combines all the different functions a specific reporter+-- must implement in a single record.+--+-- We've defined this as a GADT so we don't need to expose the `handler` and+-- `settings` parameters on the type, meaning this type is defined as `Reporter`+-- rather than `Reporter settings handler`. This allows us to combine multiple+-- reporters in a list.+data Reporter where+  Reporter ::+    { reporterName :: Text,+      -- | Pick the reporter-specific settings from the global `Settings` type+      -- defined in this module.+      reporterSettings :: Settings -> settings,+      -- | Create a handler for this reporter. This function will be called+      -- once when the application starts for each enabled reporter.+      reporterHandler :: settings -> Conduit.Acquire handler,+      -- | Report a span to this reporter.+      reporterReport :: handler -> Text -> Platform.TracingSpan -> Prelude.IO ()+    } ->+    Reporter++-- | A list containing all the reporters we support. Reporters are ordered in+-- increasing chance of failure, so we can pick the safest one for reporting on+-- failures in other reporters.+supportedReporters :: [Reporter]+supportedReporters =+  [ Reporter "stdout" stdout fileHandler File.report,+    Reporter "stdout-pretty" dev devHandler Dev.report,+    Reporter "file" file fileHandler File.report,+    Reporter "bugsnag" bugsnag bugsnagHandler Bugsnag.report,+    Reporter "honeycomb" honeycomb honeycombHandler Honeycomb.report+  ]++fileHandler :: File.Settings -> Conduit.Acquire File.Handler+fileHandler settings = Conduit.mkAcquire (File.handler settings) File.cleanup++devHandler :: () -> Conduit.Acquire Dev.Handler+devHandler _ = Conduit.mkAcquire Dev.handler Dev.cleanup++bugsnagHandler :: Bugsnag.Settings -> Conduit.Acquire Bugsnag.Handler+bugsnagHandler settings = Conduit.liftIO (Bugsnag.handler settings)++honeycombHandler :: Honeycomb.Settings -> Conduit.Acquire Honeycomb.Handler+honeycombHandler settings = Conduit.liftIO (Honeycomb.handler settings)++-- | Settings for all supported reporters.+data Settings = Settings+  { -- | The reporters that we send debugging information to. The first+    -- reporter in the list is treated special: it will also report on+    -- failures that take place while running any of the other reporters.+    -- Because of this its best if the first reporter is one that has a+    -- high likelihood of succeeding, for example because it logs to stdout+    -- or a file.+    enabledReporters :: [Reporter],+    -- | Each supported reporter has a settings entry below. We parse+    -- settings even for reporters that aren't enabled. Because our+    -- environment parser insists all variables have default values it's not+    -- necessary to make up values for reporters we don't want enabled.+    file :: File.Settings,+    bugsnag :: Bugsnag.Settings,+    honeycomb :: Honeycomb.Settings,+    dev :: ()+  }++stdout :: Settings -> File.Settings+stdout settings = (file settings) {File.logFile = "/dev/stdout"}++decoder :: Environment.Decoder Settings+decoder =+  pure Settings+    |> andMap reportersDecoder+    |> andMap File.decoder+    |> andMap Bugsnag.decoder+    |> andMap Honeycomb.decoder+    |> andMap (Prelude.pure ())++reportersDecoder :: Environment.Decoder [Reporter]+reportersDecoder =+  Environment.variable+    Environment.Variable+      { Environment.name = "LOG_ENABLED_LOGGERS",+        Environment.description = "Comma-separated list of logging destinations.",+        Environment.defaultValue = "stdout-pretty"+      }+    (Environment.custom Environment.text reportersParser)++-- Parses reporters, maintaining the same order of reporters as the+-- `supportedReporters` list.+reportersParser :: Text -> Result Text [Reporter]+reportersParser reportersString = do+  names <-+    reportersString+      |> Text.split ","+      |> traverse parseLogger+      |> map Set.fromList+  supportedReporters+    |> List.filter (\reporter -> Set.member (reporterName reporter) names)+    |> Ok++parseLogger :: Text -> Result Text Text+parseLogger name =+  let normalizedName = Text.trim (Text.toLower name)+      supportedNames =+        supportedReporters+          |> map reporterName+          |> Set.fromList+   in if Set.member normalizedName supportedNames+        then Ok normalizedName+        else Err ("Unknown reporter: " ++ normalizedName)
tests/Main.hs view
@@ -1,5 +1,6 @@ module Main (main) where +import qualified Spec.Observability import qualified Spec.Platform.Timer import qualified Spec.Reporter.Bugsnag import qualified Spec.Reporter.Dev@@ -19,5 +20,6 @@       Spec.Reporter.Bugsnag.tests,       Spec.Reporter.Dev.tests,       Spec.Reporter.File.tests,-      Spec.Reporter.Honeycomb.tests+      Spec.Reporter.Honeycomb.tests,+      Spec.Observability.tests     ]
+ tests/Spec/Observability.hs view
@@ -0,0 +1,111 @@+module Spec.Observability (tests) where++import qualified Conduit+import qualified Control.Concurrent+import qualified Control.Exception.Safe as Exception+import qualified Data.IORef as IORef+import qualified Environment+import qualified Expect+import qualified Observability+import qualified Platform+import Test (Test, describe, test)+import qualified Prelude++tests :: Test+tests =+  describe+    "Observability"+    [ test "When creating a handler for two reports when the handler is reporting a span both reporters are ran" <| \_ -> do+        settings' <- Expect.fromIO <| Environment.decode Observability.decoder+        log <- Expect.fromIO <| IORef.newIORef []+        let reporter1 = fakeReporter (\_ -> appendLog "reporter1" log)+        let reporter2 = fakeReporter (\_ -> appendLog "reporter2" log)+        let settings = settings' {Observability.enabledReporters = [reporter1, reporter2]}+        reports <-+          Expect.fromIO+            ( Conduit.withAcquire (Observability.handler settings) <| \handler -> do+                Observability.report handler "request-id-1234" emptyTracingSpan+                IORef.readIORef log+            )+        reports+          |> Expect.equal ["reporter1", "reporter2"],+      test "The first reporter reports sync exceptions thrown by the other reporters" <| \_ -> do+        settings' <- Expect.fromIO <| Environment.decode Observability.decoder+        log <- Expect.fromIO <| IORef.newIORef []+        let reporter1 = fakeReporter (\span -> appendLog ("reporter1: " ++ Platform.name span) log)+        let reporter2 = fakeReporter (\_ -> Exception.throwString "failed!")+        let settings = settings' {Observability.enabledReporters = [reporter1, reporter2]}+        reports <-+          Expect.fromIO+            ( Conduit.withAcquire (Observability.handler settings) <| \handler -> do+                Observability.report handler "request-id-1234" emptyTracingSpan+                IORef.readIORef log+            )+        reports+          |> Expect.equal ["reporter1: example", "reporter1: Failed to report span to fake"],+      test "The first reporter reports async exceptions thrown by the other reporters" <| \_ -> do+        settings' <- Expect.fromIO <| Environment.decode Observability.decoder+        log <- Expect.fromIO <| IORef.newIORef []+        let reporter1 = fakeReporter (\span -> appendLog ("reporter1: " ++ Platform.name span) log)+        let reporter2 =+              fakeReporter+                ( \_ -> do+                    threadId <- Control.Concurrent.myThreadId+                    Exception.throwTo threadId TestException+                )+        let settings = settings' {Observability.enabledReporters = [reporter1, reporter2]}+        reports <-+          Expect.fromIO <| Conduit.withAcquire (Observability.handler settings) <| \handler -> do+            Observability.report handler "request-id-1234" emptyTracingSpan+              |> Exception.handleAsync (\(Exception.AsyncExceptionWrapper _) -> Prelude.pure ())+            IORef.readIORef log+        reports+          |> Expect.equal ["reporter1: example", "reporter1: Failed to report span to fake"],+      test "A reporter that fails with a sync exception does not prevent other reporters from completing" <| \_ -> do+        settings' <- Expect.fromIO <| Environment.decode Observability.decoder+        log <- Expect.fromIO <| IORef.newIORef []+        let reporter1 = fakeReporter (\span -> appendLog ("reporter1: " ++ Platform.name span) log)+        let reporter2 = fakeReporter (\_ -> Exception.throwString "failed!")+        let reporter3 = fakeReporter (\span -> appendLog ("reporter3: " ++ Platform.name span) log)+        let settings = settings' {Observability.enabledReporters = [reporter1, reporter2, reporter3]}+        reports <-+          Expect.fromIO+            ( Conduit.withAcquire (Observability.handler settings) <| \handler -> do+                Observability.report handler "request-id-1234" emptyTracingSpan+                IORef.readIORef log+            )+        reports+          |> Expect.equal ["reporter1: example", "reporter1: Failed to report span to fake", "reporter3: example"]+    ]++data TestException = TestException deriving (Show)++instance Exception.Exception TestException++appendLog :: Text -> IORef.IORef [Text] -> Prelude.IO ()+appendLog line log =+  IORef.atomicModifyIORef'+    log+    (\prev -> (prev ++ [line], ()))++fakeReporter :: (Platform.TracingSpan -> Prelude.IO ()) -> Observability.Reporter+fakeReporter report =+  Observability.Reporter+    { Observability.reporterName = "fake",+      Observability.reporterSettings = \_ -> (),+      Observability.reporterHandler = \_ -> Prelude.pure (),+      Observability.reporterReport = \_ _ span -> report span+    }++emptyTracingSpan :: Platform.TracingSpan+emptyTracingSpan =+  Platform.emptyTracingSpan+    { Platform.name = "example",+      Platform.started = 0,+      Platform.finished = 0,+      Platform.frame = Nothing,+      Platform.details = Nothing,+      Platform.succeeded = Platform.Succeeded,+      Platform.allocated = 0,+      Platform.children = []+    }