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 +5/−0
- README.md +2/−1
- nri-observability.cabal +6/−1
- src/Observability.hs +239/−0
- tests/Main.hs +3/−1
- tests/Spec/Observability.hs +111/−0
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 = []+ }