From 16e579960e649c8a76888bbbc98e5a1a2c6a35fc Mon Sep 17 00:00:00 2001 From: Juliano Solanho Date: Thu, 7 May 2026 18:25:13 -0300 Subject: [PATCH 1/5] nri-prelude: thread analytics callback through LogHandler Adds an opaque (Aeson.Value -> IO ()) callback to LogHandler, propagated by mkHandler to every child handler. rootTracingSpanIO gains a new parameter for the callback; nullHandler defaults to silentTrack. Re-exports silentTrack from Platform for callers. This is a breaking change to rootTracingSpanIO and mkHandler. See the design doc at NoRedInk/event-platform/docs/2026-05-07-haskell-analytics-tracking-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- nri-http/test/Main.hs | 1 + nri-kafka/src/Kafka/Worker/Internal.hs | 1 + nri-kafka/src/Kafka/Worker/Partition.hs | 1 + .../scripts/memory-leak-test/Main.hs | 1 + nri-postgresql/test/ObservabilitySpec.hs | 1 + nri-prelude/src/Platform.hs | 1 + nri-prelude/src/Platform/Internal.hs | 43 ++++++++++++++----- nri-prelude/src/Test/Internal.hs | 1 + nri-prelude/tests/LogSpec.hs | 1 + nri-prelude/tests/PlatformSpec.hs | 18 +++++++- nri-redis/test/Spec/Redis.hs | 2 + 11 files changed, 60 insertions(+), 11 deletions(-) diff --git a/nri-http/test/Main.hs b/nri-http/test/Main.hs index 4855aba4..47e03eb5 100644 --- a/nri-http/test/Main.hs +++ b/nri-http/test/Main.hs @@ -233,6 +233,7 @@ spanForTask task = do Expect.fromIO <| do Platform.rootTracingSpanIO "test-request" + Platform.silentTrack (MVar.putMVar spanVar) "test-root" (\log -> Task.attempt log task) diff --git a/nri-kafka/src/Kafka/Worker/Internal.hs b/nri-kafka/src/Kafka/Worker/Internal.hs index b6d0ff40..5fee1a12 100644 --- a/nri-kafka/src/Kafka/Worker/Internal.hs +++ b/nri-kafka/src/Kafka/Worker/Internal.hs @@ -456,6 +456,7 @@ cleanUp observabilityHandler rebalanceInfo stopping maybeException consumer = do -- at some point, k8s should report system crashes. In the mean time, we'll do it. Platform.rootTracingSpanIO requestId + Platform.silentTrack (Observability.report observabilityHandler requestId) "Kafka consumer shutting down" <| \log -> do diff --git a/nri-kafka/src/Kafka/Worker/Partition.hs b/nri-kafka/src/Kafka/Worker/Partition.hs index 206c4353..9a3d3130 100644 --- a/nri-kafka/src/Kafka/Worker/Partition.hs +++ b/nri-kafka/src/Kafka/Worker/Partition.hs @@ -248,6 +248,7 @@ processMsgLoop skipOrNot messageFormat commitOffsets observabilityHandler state (RequestId requestId, details) <- getTracingDetails (analytics state) processAttempts record Platform.rootTracingSpanIO requestId + Platform.silentTrack (Observability.report observabilityHandler requestId) "Assigned Kafka message" ( \log -> do diff --git a/nri-observability/scripts/memory-leak-test/Main.hs b/nri-observability/scripts/memory-leak-test/Main.hs index ead5c1ad..91d201d0 100644 --- a/nri-observability/scripts/memory-leak-test/Main.hs +++ b/nri-observability/scripts/memory-leak-test/Main.hs @@ -35,6 +35,7 @@ runRequests handler = ( \requestId -> do Platform.rootTracingSpanIO requestId + Platform.silentTrack (handler.report requestId) ("Running task" ++ requestId) ( \log -> do diff --git a/nri-postgresql/test/ObservabilitySpec.hs b/nri-postgresql/test/ObservabilitySpec.hs index 00cc2881..ee96a301 100644 --- a/nri-postgresql/test/ObservabilitySpec.hs +++ b/nri-postgresql/test/ObservabilitySpec.hs @@ -53,6 +53,7 @@ spanForTask task = res <- Platform.rootTracingSpanIO "test-request" + Platform.silentTrack (MVar.putMVar spanVar) "test-root" (\log -> Task.attempt log task) diff --git a/nri-prelude/src/Platform.hs b/nri-prelude/src/Platform.hs index 9da52d00..d3caf61f 100644 --- a/nri-prelude/src/Platform.hs +++ b/nri-prelude/src/Platform.hs @@ -13,6 +13,7 @@ module Platform logHandler, requestId, silentHandler, + Internal.silentTrack, -- * Creating custom tracingSpans in libraries Internal.tracingSpan, diff --git a/nri-prelude/src/Platform/Internal.hs b/nri-prelude/src/Platform/Internal.hs index d39554b7..213bd5dc 100644 --- a/nri-prelude/src/Platform/Internal.hs +++ b/nri-prelude/src/Platform/Internal.hs @@ -573,9 +573,20 @@ data LogHandler = LogHandler -- platform(s) are used. Once we're done collecting data for child -- tracingSpans we'll want to add the "completed" child tracingSpan to -- its parent. - finishTracingSpan :: Maybe Exception.SomeException -> IO () + finishTracingSpan :: Maybe Exception.SomeException -> IO (), + -- | Deliver an analytics event payload to the configured analytics + -- backend. The prelude knows nothing about the backend; it only + -- threads this opaque callback from `rootTracingSpanIO` through + -- every child `LogHandler`. See `Platform.Analytics.Internal.trackEvent` + -- for the user-facing wrapper. Default: `silentTrack`. + trackAnalyticsEventIO :: Aeson.Value -> IO () } +-- | A no-op analytics callback. Used as the default for `nullHandler` +-- and for platforms that have not opted in to analytics tracking yet. +silentTrack :: Aeson.Value -> IO () +silentTrack _ = pure () + -- | Helper that creates one of the handler's above. This is intended for -- internal use in this library only and not for exposing. Outside of this -- library the @rootTracingSpanIO@ is the more user-friendly way to get hands @@ -584,13 +595,15 @@ mkHandler :: (Stack.HasCallStack) => Text -> Clock -> + -- | Analytics callback, propagated to every descendant `LogHandler`. + (Aeson.Value -> IO ()) -> -- Finalizer for this loghandler (TracingSpan -> IO ()) -> -- Root finalizer Maybe (TracingSpan -> IO ()) -> Text -> IO LogHandler -mkHandler requestId clock onFinish onFinishRoot' name' = do +mkHandler requestId clock trackEventIO onFinish onFinishRoot' name' = do let onFinishRoot = Maybe.withDefault onFinish onFinishRoot' tracingSpanRef <- Stack.withFrozenCallStack startTracingSpan clock name' @@ -599,8 +612,8 @@ mkHandler requestId clock onFinish onFinishRoot' name' = do pure LogHandler { requestId, - startChildTracingSpan = mkHandler requestId clock (appendTracingSpanToParent tracingSpanRef) (Just onFinishRoot), - startNewRoot = mkHandler requestId clock onFinishRoot Nothing, + startChildTracingSpan = mkHandler requestId clock trackEventIO (appendTracingSpanToParent tracingSpanRef) (Just onFinishRoot), + startNewRoot = mkHandler requestId clock trackEventIO onFinishRoot Nothing, setTracingSpanDetailsIO = \details' -> updateIORef tracingSpanRef @@ -613,7 +626,8 @@ mkHandler requestId clock onFinish onFinishRoot' name' = do updateIORef tracingSpanRef (\tracingSpan' -> tracingSpan' {succeeded = succeeded tracingSpan' ++ Failed, containsFailures = True}), - finishTracingSpan = finalizeTracingSpan clock allocationCounterStartVal tracingSpanRef >> andThen onFinish + finishTracingSpan = finalizeTracingSpan clock allocationCounterStartVal tracingSpanRef >> andThen onFinish, + trackAnalyticsEventIO = trackEventIO } -- | Helper that creates a handler that does nothing. This is intended to power @@ -632,7 +646,8 @@ nullHandler = do setTracingSpanDetailsIO = \_ -> pure (), setTracingSpanSummaryIO = \_ -> pure (), markTracingSpanFailedIO = pure (), - finishTracingSpan = \_ -> pure () + finishTracingSpan = \_ -> pure (), + trackAnalyticsEventIO = silentTrack } -- | Set the details for a tracingSpan created using the @tracingSpan@ @@ -842,14 +857,22 @@ newRootIO handler name run = do -- Instead of taking a parent handler it takes a continuation that will be -- called with this root tracingSpan after it has run. -- --- > rootTracingSpanIO "request-23" Prelude.print "incoming request" <| \handler -> +-- > rootTracingSpanIO "request-23" silentTrack Prelude.print "incoming request" <| \handler -> -- > handleRequest -- > |> Task.perform handler -rootTracingSpanIO :: (Stack.HasCallStack) => Text -> (TracingSpan -> IO ()) -> Text -> (LogHandler -> IO a) -> IO a -rootTracingSpanIO requestId onFinish name runIO = do +rootTracingSpanIO :: + (Stack.HasCallStack) => + Text -> + -- | Analytics callback. Pass `silentTrack` for platforms that don't track. + (Aeson.Value -> IO ()) -> + (TracingSpan -> IO ()) -> + Text -> + (LogHandler -> IO a) -> + IO a +rootTracingSpanIO requestId trackEventIO onFinish name runIO = do clock' <- mkClock Exception.bracketWithError - (Stack.withFrozenCallStack mkHandler requestId clock' onFinish Nothing name) + (Stack.withFrozenCallStack mkHandler requestId clock' trackEventIO onFinish Nothing name) (Prelude.flip finishTracingSpan) runIO diff --git a/nri-prelude/src/Test/Internal.hs b/nri-prelude/src/Test/Internal.hs index a51f79a7..0379f399 100644 --- a/nri-prelude/src/Test/Internal.hs +++ b/nri-prelude/src/Test/Internal.hs @@ -533,6 +533,7 @@ runSingle test' = res <- Platform.Internal.rootTracingSpanIO "" + Platform.Internal.silentTrack ( \span -> do when (Platform.Internal.name span == spanName) <| MVar.putMVar spanVar span diff --git a/nri-prelude/tests/LogSpec.hs b/nri-prelude/tests/LogSpec.hs index 8cc12820..d75d4c8f 100644 --- a/nri-prelude/tests/LogSpec.hs +++ b/nri-prelude/tests/LogSpec.hs @@ -195,6 +195,7 @@ newHandler = do Internal.mkHandler "" (Internal.Clock (Prelude.pure 0)) + Internal.silentTrack (\span -> IORef.modifyIORef recordedTracingSpans (\cs -> cs ++ Internal.children span)) Nothing "" diff --git a/nri-prelude/tests/PlatformSpec.hs b/nri-prelude/tests/PlatformSpec.hs index 37a70a1a..a511d17f 100644 --- a/nri-prelude/tests/PlatformSpec.hs +++ b/nri-prelude/tests/PlatformSpec.hs @@ -3,10 +3,12 @@ module PlatformSpec (tests) where import qualified Control.Concurrent.MVar as MVar import Control.Monad.Catch (catchAll) import Data.Aeson as Aeson +import qualified Data.IORef as IORef import qualified Expect import qualified Log import NriPrelude import qualified Platform +import qualified Platform.Internal import Task import Test (Test, describe, test) import qualified Prelude @@ -31,7 +33,20 @@ tests = runTaskAndExpectTacingSpan <| Log.error "error" [] Expect.true (isSucceeded span) - Expect.true (Platform.containsFailures span) + Expect.true (Platform.containsFailures span), + test "trackAnalyticsEventIO threaded by rootTracingSpanIO is invoked from a child span" <| \_ -> do + ref <- Expect.fromIO (IORef.newIORef []) + let track v = IORef.atomicModifyIORef' ref (\xs -> (v : xs, ())) + Expect.fromIO + <| Platform.rootTracingSpanIO "test-req" track (\_ -> Prelude.pure ()) "root" + <| \log -> do + child <- Platform.Internal.startChildTracingSpan log "child-span" + Platform.Internal.trackAnalyticsEventIO child (Aeson.toJSON ("hello" :: Text)) + observed <- Expect.fromIO (IORef.readIORef ref) + observed |> Expect.equal [Aeson.toJSON ("hello" :: Text)], + test "nullHandler.trackAnalyticsEventIO is a silent no-op" <| \_ -> + Expect.fromIO + <| Platform.Internal.trackAnalyticsEventIO Platform.Internal.nullHandler Aeson.Null ] newtype CustomTracingSpanDetails = CustomTracingSpanDetails Text @@ -48,6 +63,7 @@ runTaskAndExpectTacingSpan task = catchAll ( Platform.rootTracingSpanIO "" + Platform.silentTrack (MVar.putMVar spanVar) "test" (\log -> Task.attempt log task) diff --git a/nri-redis/test/Spec/Redis.hs b/nri-redis/test/Spec/Redis.hs index 85ffca1a..4e7af3d8 100644 --- a/nri-redis/test/Spec/Redis.hs +++ b/nri-redis/test/Spec/Redis.hs @@ -30,6 +30,7 @@ spanForTask task = res <- Platform.rootTracingSpanIO "test-request" + Platform.silentTrack (MVar.putMVar spanVar) "test-root" (\log -> Task.attempt log task) @@ -46,6 +47,7 @@ spanForFailingTask task = res <- Platform.rootTracingSpanIO "test-request" + Platform.silentTrack (MVar.putMVar spanVar) "test-root" (\log -> Task.attempt log task) From 34a5d267b604c5d5cbcf624d84cfdf82a5fc8915 Mon Sep 17 00:00:00 2001 From: Juliano Solanho Date: Thu, 7 May 2026 18:33:52 -0300 Subject: [PATCH 2/5] nri-prelude: add Platform.Analytics.Internal.trackEvent trackEvent opens an analytics.track child span, attaches the JSON payload as span details, and invokes the LogHandler's analytics callback. The .Internal suffix signals do-not-import from product code; consumers should wrap this in a typed entry point. Bump to 0.7.0.0 (breaking: rootTracingSpanIO signature changed in the previous commit). Bump nri-prelude upper bound to <0.8 in the sibling packages so the workspace builds, and refresh hard-coded package strings in the test-suite golden files. Co-Authored-By: Claude Opus 4.7 (1M context) --- nri-env-parser/nri-env-parser.cabal | 4 +- nri-env-parser/package.yaml | 2 +- nri-http/nri-http.cabal | 4 +- nri-http/package.yaml | 4 +- nri-kafka/nri-kafka.cabal | 8 ++-- nri-kafka/package.yaml | 2 +- nri-log-explorer/nri-log-explorer.cabal | 2 +- nri-log-explorer/package.yaml | 2 +- nri-observability/nri-observability.cabal | 6 +-- nri-observability/package.yaml | 2 +- nri-postgresql/nri-postgresql.cabal | 4 +- nri-postgresql/package.yaml | 2 +- nri-prelude/CHANGELOG.md | 7 +++ nri-prelude/nri-prelude.cabal | 4 +- nri-prelude/package.yaml | 3 +- .../src/Platform/Analytics/Internal.hs | 44 +++++++++++++++++++ nri-prelude/tests/PlatformSpec.hs | 16 ++++++- nri-prelude/tests/golden-results-9.10/debug | 2 +- .../golden-results-9.10/debug-todo-stacktrace | 4 +- nri-prelude/tests/golden-results-9.10/error | 2 +- .../golden-results-9.10/log-async-exceptions | 4 +- .../tests/golden-results-9.10/log-info | 2 +- .../golden-results-9.10/log-nested-spans | 6 +-- .../tests/golden-results-9.10/log-new-root | 10 ++--- .../log-unexpected-exceptions | 4 +- .../test-report-logfile-all-passed | 2 +- .../test-report-logfile-no-tests-in-suite | 2 +- .../test-report-logfile-onlys-passed | 2 +- .../test-report-logfile-passed-with-skipped | 2 +- .../test-report-logfile-tests-failed | 2 +- nri-prelude/tests/golden-results-9.10/warn | 2 +- nri-prelude/tests/golden-results-9.12/debug | 2 +- nri-prelude/tests/golden-results-9.12/error | 2 +- .../golden-results-9.12/log-async-exceptions | 4 +- .../tests/golden-results-9.12/log-info | 2 +- .../golden-results-9.12/log-nested-spans | 6 +-- .../tests/golden-results-9.12/log-new-root | 10 ++--- .../log-unexpected-exceptions | 4 +- .../test-report-logfile-all-passed | 2 +- .../test-report-logfile-no-tests-in-suite | 2 +- .../test-report-logfile-onlys-passed | 2 +- .../test-report-logfile-passed-with-skipped | 2 +- .../test-report-logfile-tests-failed | 2 +- nri-prelude/tests/golden-results-9.12/warn | 2 +- nri-prelude/tests/golden-results-9.8/debug | 2 +- .../golden-results-9.8/debug-todo-stacktrace | 2 +- nri-prelude/tests/golden-results-9.8/error | 2 +- .../golden-results-9.8/log-async-exceptions | 4 +- nri-prelude/tests/golden-results-9.8/log-info | 2 +- .../tests/golden-results-9.8/log-nested-spans | 6 +-- .../tests/golden-results-9.8/log-new-root | 10 ++--- .../log-unexpected-exceptions | 4 +- .../test-report-logfile-all-passed | 2 +- .../test-report-logfile-no-tests-in-suite | 2 +- .../test-report-logfile-onlys-passed | 2 +- .../test-report-logfile-passed-with-skipped | 2 +- .../test-report-logfile-tests-failed | 2 +- nri-prelude/tests/golden-results-9.8/warn | 2 +- nri-redis/nri-redis.cabal | 4 +- nri-redis/package.yaml | 2 +- nri-test-encoding/nri-test-encoding.cabal | 4 +- nri-test-encoding/package.yaml | 2 +- 62 files changed, 164 insertions(+), 96 deletions(-) create mode 100644 nri-prelude/src/Platform/Analytics/Internal.hs diff --git a/nri-env-parser/nri-env-parser.cabal b/nri-env-parser/nri-env-parser.cabal index c7b0be94..7f11f793 100644 --- a/nri-env-parser/nri-env-parser.cabal +++ b/nri-env-parser/nri-env-parser.cabal @@ -53,7 +53,7 @@ library base >=4.18 && <4.22 , modern-uri >=0.3.1.0 && <0.4 , network-uri >=2.6.2.0 && <2.8 - , nri-prelude >=0.1.0.0 && <0.7 + , nri-prelude >=0.1.0.0 && <0.8 , text >=1.2.3.1 && <2.2 default-language: Haskell2010 @@ -86,6 +86,6 @@ test-suite tests base >=4.18 && <4.22 , modern-uri >=0.3.1.0 && <0.4 , network-uri >=2.6.2.0 && <2.8 - , nri-prelude >=0.1.0.0 && <0.7 + , nri-prelude >=0.1.0.0 && <0.8 , text >=1.2.3.1 && <2.2 default-language: Haskell2010 diff --git a/nri-env-parser/package.yaml b/nri-env-parser/package.yaml index ad7e094c..a6d13416 100644 --- a/nri-env-parser/package.yaml +++ b/nri-env-parser/package.yaml @@ -16,7 +16,7 @@ extra-doc-files: library: dependencies: &dependencies - base >= 4.18 && < 4.22 - - nri-prelude >= 0.1.0.0 && < 0.7 + - nri-prelude >= 0.1.0.0 && < 0.8 - modern-uri >= 0.3.1.0 && < 0.4 - network-uri >= 2.6.2.0 && < 2.8 - text >= 1.2.3.1 && < 2.2 diff --git a/nri-http/nri-http.cabal b/nri-http/nri-http.cabal index 3dd910d9..a828cf46 100644 --- a/nri-http/nri-http.cabal +++ b/nri-http/nri-http.cabal @@ -65,7 +65,7 @@ library , mime-types >=0.1.0.0 && <0.2 , network-uri >=2.6.0.0 && <2.8 , nri-observability >=0.1.0.0 && <0.5 - , nri-prelude >=0.1.0.0 && <0.7 + , nri-prelude >=0.1.0.0 && <0.8 , safe-exceptions >=0.1.7.0 && <1.3 , text >=1.2.3.1 && <2.2 default-language: Haskell2010 @@ -111,7 +111,7 @@ test-suite spec , mime-types >=0.1.0.0 && <0.2 , network-uri >=2.6.0.0 && <2.8 , nri-observability >=0.1.0.0 && <0.4 - , nri-prelude >=0.1.0.0 && <0.7 + , nri-prelude >=0.1.0.0 && <0.8 , safe-exceptions >=0.1.7.0 && <1.3 , text >=1.2.3.1 && <2.2 , wai >=3.2.0 && <3.3 diff --git a/nri-http/package.yaml b/nri-http/package.yaml index 28d8708d..64eb437f 100644 --- a/nri-http/package.yaml +++ b/nri-http/package.yaml @@ -17,7 +17,7 @@ library: - aeson >= 2.0 && < 2.3 - base >= 4.18 && < 4.22 - bytestring >= 0.10.8.2 && < 0.13 - - nri-prelude >= 0.1.0.0 && < 0.7 + - nri-prelude >= 0.1.0.0 && < 0.8 - nri-observability >= 0.1.0.0 && < 0.5 - conduit >= 1.3.0 && < 1.4 - case-insensitive >= 1.1 && < 2.0 @@ -38,7 +38,7 @@ tests: - aeson >= 2.0 && < 2.3 - base >= 4.18 && < 4.22 - bytestring >= 0.10.8.2 && < 0.13 - - nri-prelude >= 0.1.0.0 && < 0.7 + - nri-prelude >= 0.1.0.0 && < 0.8 - nri-observability >= 0.1.0.0 && < 0.4 - conduit >= 1.3.0 && < 1.4 - case-insensitive >= 1.1 && < 2.0 diff --git a/nri-kafka/nri-kafka.cabal b/nri-kafka/nri-kafka.cabal index 97899901..d59de942 100644 --- a/nri-kafka/nri-kafka.cabal +++ b/nri-kafka/nri-kafka.cabal @@ -76,7 +76,7 @@ library , hw-kafka-client >=4.0.3 && <5.0 , nri-env-parser >=0.1.0.0 && <0.5 , nri-observability >=0.1.1.1 && <0.5 - , nri-prelude >=0.1.0.0 && <0.7 + , nri-prelude >=0.1.0.0 && <0.8 , safe-exceptions >=0.1.7.0 && <1.3 , stm >=2.4 && <2.6 , text >=1.2.3.1 && <2.2 @@ -121,7 +121,7 @@ executable pause-resume-bug-consumer , nri-env-parser >=0.1.0.0 && <0.5 , nri-kafka , nri-observability >=0.1.1.1 && <0.5 - , nri-prelude >=0.1.0.0 && <0.7 + , nri-prelude >=0.1.0.0 && <0.8 , safe-exceptions >=0.1.7.0 && <1.3 , stm >=2.4 && <2.6 , text >=1.2.3.1 && <2.2 @@ -170,7 +170,7 @@ executable pause-resume-bug-producer , nri-env-parser >=0.1.0.0 && <0.5 , nri-kafka , nri-observability >=0.1.1.1 && <0.5 - , nri-prelude >=0.1.0.0 && <0.7 + , nri-prelude >=0.1.0.0 && <0.8 , safe-exceptions >=0.1.7.0 && <1.3 , stm >=2.4 && <2.6 , text >=1.2.3.1 && <2.2 @@ -233,7 +233,7 @@ test-suite tests , hw-kafka-client >=4.0.3 && <5.0 , nri-env-parser >=0.1.0.0 && <0.5 , nri-observability >=0.1.1.1 && <0.5 - , nri-prelude >=0.1.0.0 && <0.7 + , nri-prelude >=0.1.0.0 && <0.8 , safe-exceptions >=0.1.7.0 && <1.3 , stm >=2.4 && <2.6 , text >=1.2.3.1 && <2.2 diff --git a/nri-kafka/package.yaml b/nri-kafka/package.yaml index 639a9ac0..b298504f 100644 --- a/nri-kafka/package.yaml +++ b/nri-kafka/package.yaml @@ -23,7 +23,7 @@ dependencies: - hw-kafka-client >=4.0.3 && < 5.0 - nri-env-parser >= 0.1.0.0 && < 0.5 - nri-observability >= 0.1.1.1 && < 0.5 - - nri-prelude >= 0.1.0.0 && < 0.7 + - nri-prelude >= 0.1.0.0 && < 0.8 - safe-exceptions >= 0.1.7.0 && < 1.3 - stm >= 2.4 && < 2.6 - text >= 1.2.3.1 && < 2.2 diff --git a/nri-log-explorer/nri-log-explorer.cabal b/nri-log-explorer/nri-log-explorer.cabal index 68dc3402..99ab547b 100644 --- a/nri-log-explorer/nri-log-explorer.cabal +++ b/nri-log-explorer/nri-log-explorer.cabal @@ -59,7 +59,7 @@ executable log-explorer , fuzzy >=0.1.0.0 && <0.2 , io-streams >=1.5.0.0 && <1.6 , microlens >=0.4.11.0 && <0.5 - , nri-prelude >=0.1.0.0 && <0.7 + , nri-prelude >=0.1.0.0 && <0.8 , pcre-light >=0.4.1.0 && <0.4.2 , process >=1.6.0.0 && <1.7 , safe-exceptions >=0.1.7.0 && <1.3 diff --git a/nri-log-explorer/package.yaml b/nri-log-explorer/package.yaml index a543821a..ad88fc83 100644 --- a/nri-log-explorer/package.yaml +++ b/nri-log-explorer/package.yaml @@ -28,7 +28,7 @@ executables: - pcre-light >= 0.4.1.0 && < 0.4.2 - unordered-containers >= 0.2.0.0 && < 0.3 - microlens >= 0.4.11.0 && < 0.5 - - nri-prelude >= 0.1.0.0 && < 0.7 + - nri-prelude >= 0.1.0.0 && < 0.8 - process >= 1.6.0.0 && < 1.7 - safe-exceptions >= 0.1.7.0 && < 1.3 - text >= 1.2.3.1 && < 2.2 diff --git a/nri-observability/nri-observability.cabal b/nri-observability/nri-observability.cabal index 57647282..a9fc96dd 100644 --- a/nri-observability/nri-observability.cabal +++ b/nri-observability/nri-observability.cabal @@ -77,7 +77,7 @@ library , http-client >=0.6.0 && <0.8 , http-client-tls >=0.3.0 && <0.4 , nri-env-parser >=0.1.0.0 && <0.5 - , nri-prelude >=0.1.0.0 && <0.7 + , nri-prelude >=0.1.0.0 && <0.8 , random >=1.1 && <1.3 , safe-exceptions >=0.1.7.0 && <1.3 , stm >=2.4 && <2.6 @@ -123,7 +123,7 @@ executable memory-leak-test , http-client-tls >=0.3.0 && <0.4 , nri-env-parser >=0.1.0.0 && <0.5 , nri-observability - , nri-prelude >=0.1.0.0 && <0.7 + , nri-prelude >=0.1.0.0 && <0.8 , random >=1.1 && <1.3 , safe-exceptions >=0.1.7.0 && <1.3 , stm >=2.4 && <2.6 @@ -194,7 +194,7 @@ test-suite tests , http-client >=0.6.0 && <0.8 , http-client-tls >=0.3.0 && <0.4 , nri-env-parser >=0.1.0.0 && <0.5 - , nri-prelude >=0.1.0.0 && <0.7 + , nri-prelude >=0.1.0.0 && <0.8 , random >=1.1 && <1.3 , safe-exceptions >=0.1.7.0 && <1.3 , stm >=2.4 && <2.6 diff --git a/nri-observability/package.yaml b/nri-observability/package.yaml index b5e329be..04e2daff 100644 --- a/nri-observability/package.yaml +++ b/nri-observability/package.yaml @@ -26,7 +26,7 @@ dependencies: - http-client-tls >= 0.3.0 && < 0.4 - hostname >= 1.0 && < 1.1 - nri-env-parser >= 0.1.0.0 && < 0.5 - - nri-prelude >= 0.1.0.0 && < 0.7 + - nri-prelude >= 0.1.0.0 && < 0.8 - random >= 1.1 && < 1.3 - unordered-containers >= 0.2.0.0 && < 0.3 - safe-exceptions >= 0.1.7.0 && < 1.3 diff --git a/nri-postgresql/nri-postgresql.cabal b/nri-postgresql/nri-postgresql.cabal index 30dd14b8..1edca8dd 100644 --- a/nri-postgresql/nri-postgresql.cabal +++ b/nri-postgresql/nri-postgresql.cabal @@ -68,7 +68,7 @@ library , network >=3.1.0.0 && <3.3 , nri-env-parser >=0.1.0.0 && <0.5 , nri-observability >=0.1.0.0 && <0.5 - , nri-prelude >=0.1.0.0 && <0.7 + , nri-prelude >=0.1.0.0 && <0.8 , postgresql-typed ==0.6.* , resource-pool >=0.2.0.0 && <0.5 , resourcet >=1.2.0 && <1.4 @@ -126,7 +126,7 @@ test-suite tests , network >=3.1.0.0 && <3.3 , nri-env-parser >=0.1.0.0 && <0.5 , nri-observability >=0.1.0.0 && <0.5 - , nri-prelude >=0.1.0.0 && <0.7 + , nri-prelude >=0.1.0.0 && <0.8 , postgresql-typed ==0.6.* , resource-pool >=0.2.0.0 && <0.5 , resourcet >=1.2.0 && <1.4 diff --git a/nri-postgresql/package.yaml b/nri-postgresql/package.yaml index a29614a3..e8ec7c95 100644 --- a/nri-postgresql/package.yaml +++ b/nri-postgresql/package.yaml @@ -23,7 +23,7 @@ dependencies: - network >= 3.1.0.0 && < 3.3 - nri-env-parser >= 0.1.0.0 && < 0.5 - nri-observability >= 0.1.0.0 && < 0.5 - - nri-prelude >= 0.1.0.0 && < 0.7 + - nri-prelude >= 0.1.0.0 && < 0.8 - postgresql-typed >= 0.6 && < 0.7 - resource-pool >= 0.2.0.0 && < 0.5 - resourcet >= 1.2.0 && < 1.4 diff --git a/nri-prelude/CHANGELOG.md b/nri-prelude/CHANGELOG.md index 5d688acd..badea395 100644 --- a/nri-prelude/CHANGELOG.md +++ b/nri-prelude/CHANGELOG.md @@ -1,3 +1,10 @@ +# 0.7.0.0 + +- **Breaking:** `Platform.rootTracingSpanIO` and the internal `mkHandler` now take an additional `Aeson.Value -> IO ()` callback for analytics event delivery. Existing callers should pass `Platform.silentTrack` to preserve previous behavior. +- New: `Platform.Analytics.Internal.trackEvent`. The `.Internal` suffix is intentional — wrap it in your own typed `track` API. This module is NOT re-exported from `Platform`. +- New: `Platform.silentTrack` (a no-op `Aeson.Value -> IO ()`). +- New: `LogHandler.trackAnalyticsEventIO` field. `nullHandler` defaults this to `silentTrack`. + # Unreleased - Drop support for GHC 8.10.7, GHC 9.2.x, GHC 9.4.x, `aeson-1.x` diff --git a/nri-prelude/nri-prelude.cabal b/nri-prelude/nri-prelude.cabal index 3ccd3026..73276f4e 100644 --- a/nri-prelude/nri-prelude.cabal +++ b/nri-prelude/nri-prelude.cabal @@ -5,7 +5,7 @@ cabal-version: 1.18 -- see: https://github.com/sol/hpack name: nri-prelude -version: 0.6.1.2 +version: 0.7.0.0 synopsis: A Prelude inspired by the Elm programming language description: Please see the README at . category: Web @@ -45,6 +45,7 @@ library NriPrelude NriPrelude.Plugin Platform + Platform.Analytics.Internal Process Result Set @@ -146,6 +147,7 @@ test-suite tests NriPrelude.Plugin NriPrelude.Plugin.GhcVersionDependent Platform + Platform.Analytics.Internal Platform.DevLog Platform.DoAnything Platform.Internal diff --git a/nri-prelude/package.yaml b/nri-prelude/package.yaml index f3d28375..cd21b863 100644 --- a/nri-prelude/package.yaml +++ b/nri-prelude/package.yaml @@ -3,7 +3,7 @@ synopsis: A Prelude inspired by the Elm programming language description: Please see the README at . homepage: https://github.com/NoRedInk/haskell-libraries/tree/trunk/nri-prelude#readme author: NoRedInk -version: 0.6.1.2 +version: 0.7.0.0 maintainer: haskell-open-source@noredink.com copyright: 2024 NoRedInk Corp. github: NoRedInk/haskell-libraries/nri-prelude @@ -57,6 +57,7 @@ library: - NriPrelude - NriPrelude.Plugin - Platform + - Platform.Analytics.Internal - Process - Result - Set diff --git a/nri-prelude/src/Platform/Analytics/Internal.hs b/nri-prelude/src/Platform/Analytics/Internal.hs new file mode 100644 index 00000000..46b32a28 --- /dev/null +++ b/nri-prelude/src/Platform/Analytics/Internal.hs @@ -0,0 +1,44 @@ +{-# LANGUAGE FlexibleInstances #-} + +-- | Internal entry point for emitting analytics events from `Task` code. +-- +-- This module is intentionally `.Internal`. It is NOT re-exported from +-- the prelude's public `Platform` module. Higher layers (NoRedInk's +-- `Analytics.track`) wrap this and own the user-facing API; downstream +-- code that imports `Platform.Analytics.Internal` directly is bypassing +-- the closed event dictionary and should be flagged in review. +module Platform.Analytics.Internal + ( trackEvent, + AnalyticsEventDetails, + ) +where + +import qualified Data.Aeson as Aeson +import NriPrelude +import qualified Platform +import qualified Platform.Internal as Internal +import Task (Task) +import qualified Prelude + +-- | Send an analytics event. Opens a child tracing span named +-- @analytics.track@, attaches the JSON payload as the span's details, +-- and synchronously invokes the `LogHandler`'s analytics callback. +trackEvent :: (Aeson.ToJSON e) => e -> Task err () +trackEvent event = + let value = Aeson.toJSON event + in Platform.tracingSpan "analytics.track" <| do + Platform.setTracingSpanDetails (AnalyticsEventDetails value) + Internal.Task + ( \handler -> do + Internal.trackAnalyticsEventIO handler value + Prelude.pure (Ok ()) + ) + +-- | A `TracingSpanDetails` wrapper around the analytics event payload, so +-- that the JSON we send to the analytics backend is also attached to the +-- @analytics.track@ span and visible in the existing observability +-- reporters. +newtype AnalyticsEventDetails = AnalyticsEventDetails Aeson.Value + deriving (Aeson.ToJSON) + +instance Internal.TracingSpanDetails AnalyticsEventDetails diff --git a/nri-prelude/tests/PlatformSpec.hs b/nri-prelude/tests/PlatformSpec.hs index a511d17f..2908dffb 100644 --- a/nri-prelude/tests/PlatformSpec.hs +++ b/nri-prelude/tests/PlatformSpec.hs @@ -8,6 +8,7 @@ import qualified Expect import qualified Log import NriPrelude import qualified Platform +import qualified Platform.Analytics.Internal import qualified Platform.Internal import Task import Test (Test, describe, test) @@ -46,7 +47,20 @@ tests = observed |> Expect.equal [Aeson.toJSON ("hello" :: Text)], test "nullHandler.trackAnalyticsEventIO is a silent no-op" <| \_ -> Expect.fromIO - <| Platform.Internal.trackAnalyticsEventIO Platform.Internal.nullHandler Aeson.Null + <| Platform.Internal.trackAnalyticsEventIO Platform.Internal.nullHandler Aeson.Null, + test "Platform.Analytics.Internal.trackEvent invokes the current LogHandler's analytics callback with toJSON of the event" <| \_ -> do + ref <- Expect.fromIO (IORef.newIORef []) + let track v = IORef.atomicModifyIORef' ref (\xs -> (v : xs, ())) + let event = Aeson.object ["kind" Aeson..= ("LessonStarted" :: Text)] + result <- + Expect.fromIO + <| Platform.rootTracingSpanIO "test-req" track (\_ -> Prelude.pure ()) "root" + <| \log -> Task.attempt log (Platform.Analytics.Internal.trackEvent event) + case result of + Ok () -> Expect.pass + Err _ -> Expect.fail "trackEvent task failed" + observed <- Expect.fromIO (IORef.readIORef ref) + observed |> Expect.equal [event] ] newtype CustomTracingSpanDetails = CustomTracingSpanDetails Text diff --git a/nri-prelude/tests/golden-results-9.10/debug b/nri-prelude/tests/golden-results-9.10/debug index a2629768..6b3bcde9 100644 --- a/nri-prelude/tests/golden-results-9.10/debug +++ b/nri-prelude/tests/golden-results-9.10/debug @@ -6,7 +6,7 @@ Just ( "debug" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 40 diff --git a/nri-prelude/tests/golden-results-9.10/debug-todo-stacktrace b/nri-prelude/tests/golden-results-9.10/debug-todo-stacktrace index bfe57c72..544c8666 100644 --- a/nri-prelude/tests/golden-results-9.10/debug-todo-stacktrace +++ b/nri-prelude/tests/golden-results-9.10/debug-todo-stacktrace @@ -1,6 +1,6 @@ foo CallStack (from HasCallStack): - todo, called at tests/TestSpec.hs:177:20 in nri-prelude-0.6.1.2-inplace-tests:TestSpec + todo, called at tests/TestSpec.hs:177:20 in nri-prelude-0.7.0.0-inplace-tests:TestSpec HasCallStack backtrace: - todo, called at tests/TestSpec.hs:177:20 in nri-prelude-0.6.1.2-inplace-tests:TestSpec + todo, called at tests/TestSpec.hs:177:20 in nri-prelude-0.7.0.0-inplace-tests:TestSpec diff --git a/nri-prelude/tests/golden-results-9.10/error b/nri-prelude/tests/golden-results-9.10/error index 4b550b43..0ccd5662 100644 --- a/nri-prelude/tests/golden-results-9.10/error +++ b/nri-prelude/tests/golden-results-9.10/error @@ -6,7 +6,7 @@ Just ( "error" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 66 diff --git a/nri-prelude/tests/golden-results-9.10/log-async-exceptions b/nri-prelude/tests/golden-results-9.10/log-async-exceptions index 9960f182..fb749c61 100644 --- a/nri-prelude/tests/golden-results-9.10/log-async-exceptions +++ b/nri-prelude/tests/golden-results-9.10/log-async-exceptions @@ -6,7 +6,7 @@ Just ( "withContext" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 113 @@ -29,7 +29,7 @@ Just ( "withContext" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 112 diff --git a/nri-prelude/tests/golden-results-9.10/log-info b/nri-prelude/tests/golden-results-9.10/log-info index 9bc7042a..2d8e3184 100644 --- a/nri-prelude/tests/golden-results-9.10/log-info +++ b/nri-prelude/tests/golden-results-9.10/log-info @@ -6,7 +6,7 @@ Just ( "info" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 30 diff --git a/nri-prelude/tests/golden-results-9.10/log-nested-spans b/nri-prelude/tests/golden-results-9.10/log-nested-spans index 5bee1860..9a2ea38d 100644 --- a/nri-prelude/tests/golden-results-9.10/log-nested-spans +++ b/nri-prelude/tests/golden-results-9.10/log-nested-spans @@ -6,7 +6,7 @@ Just ( "withContext" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 81 @@ -29,7 +29,7 @@ Just ( "withContext" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 80 @@ -52,7 +52,7 @@ Just ( "info" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 79 diff --git a/nri-prelude/tests/golden-results-9.10/log-new-root b/nri-prelude/tests/golden-results-9.10/log-new-root index 0547be4d..f08dcf87 100644 --- a/nri-prelude/tests/golden-results-9.10/log-new-root +++ b/nri-prelude/tests/golden-results-9.10/log-new-root @@ -6,7 +6,7 @@ Just ( "info" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 174 @@ -30,7 +30,7 @@ Just ( "info" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 177 @@ -54,7 +54,7 @@ Just ( "info" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 176 @@ -78,7 +78,7 @@ Just ( "withContext" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 173 @@ -101,7 +101,7 @@ Just ( "info" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 173 diff --git a/nri-prelude/tests/golden-results-9.10/log-unexpected-exceptions b/nri-prelude/tests/golden-results-9.10/log-unexpected-exceptions index 94fa4f8f..9deeb593 100644 --- a/nri-prelude/tests/golden-results-9.10/log-unexpected-exceptions +++ b/nri-prelude/tests/golden-results-9.10/log-unexpected-exceptions @@ -6,7 +6,7 @@ Just ( "withContext" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 94 @@ -29,7 +29,7 @@ Just ( "withContext" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 93 diff --git a/nri-prelude/tests/golden-results-9.10/test-report-logfile-all-passed b/nri-prelude/tests/golden-results-9.10/test-report-logfile-all-passed index 085c55b8..d752330c 100644 --- a/nri-prelude/tests/golden-results-9.10/test-report-logfile-all-passed +++ b/nri-prelude/tests/golden-results-9.10/test-report-logfile-all-passed @@ -61,7 +61,7 @@ "file": "tests/TestSpec.hs", "module": "TestSpec", "name": "report", - "package": "nri-prelude-0.6.1.2-inplace-tests", + "package": "nri-prelude-0.7.0.0-inplace-tests", "startCol": 22, "startLine": 430 }, diff --git a/nri-prelude/tests/golden-results-9.10/test-report-logfile-no-tests-in-suite b/nri-prelude/tests/golden-results-9.10/test-report-logfile-no-tests-in-suite index 4fb70627..214949fd 100644 --- a/nri-prelude/tests/golden-results-9.10/test-report-logfile-no-tests-in-suite +++ b/nri-prelude/tests/golden-results-9.10/test-report-logfile-no-tests-in-suite @@ -10,7 +10,7 @@ "file": "tests/TestSpec.hs", "module": "TestSpec", "name": "report", - "package": "nri-prelude-0.6.1.2-inplace-tests", + "package": "nri-prelude-0.7.0.0-inplace-tests", "startCol": 22, "startLine": 469 }, diff --git a/nri-prelude/tests/golden-results-9.10/test-report-logfile-onlys-passed b/nri-prelude/tests/golden-results-9.10/test-report-logfile-onlys-passed index 9025a273..d4b4788c 100644 --- a/nri-prelude/tests/golden-results-9.10/test-report-logfile-onlys-passed +++ b/nri-prelude/tests/golden-results-9.10/test-report-logfile-onlys-passed @@ -61,7 +61,7 @@ "file": "tests/TestSpec.hs", "module": "TestSpec", "name": "report", - "package": "nri-prelude-0.6.1.2-inplace-tests", + "package": "nri-prelude-0.7.0.0-inplace-tests", "startCol": 22, "startLine": 445 }, diff --git a/nri-prelude/tests/golden-results-9.10/test-report-logfile-passed-with-skipped b/nri-prelude/tests/golden-results-9.10/test-report-logfile-passed-with-skipped index 4d09310e..674a9cf4 100644 --- a/nri-prelude/tests/golden-results-9.10/test-report-logfile-passed-with-skipped +++ b/nri-prelude/tests/golden-results-9.10/test-report-logfile-passed-with-skipped @@ -61,7 +61,7 @@ "file": "tests/TestSpec.hs", "module": "TestSpec", "name": "report", - "package": "nri-prelude-0.6.1.2-inplace-tests", + "package": "nri-prelude-0.7.0.0-inplace-tests", "startCol": 22, "startLine": 460 }, diff --git a/nri-prelude/tests/golden-results-9.10/test-report-logfile-tests-failed b/nri-prelude/tests/golden-results-9.10/test-report-logfile-tests-failed index 20a66ae9..e622f4ff 100644 --- a/nri-prelude/tests/golden-results-9.10/test-report-logfile-tests-failed +++ b/nri-prelude/tests/golden-results-9.10/test-report-logfile-tests-failed @@ -109,7 +109,7 @@ "file": "tests/TestSpec.hs", "module": "TestSpec", "name": "report", - "package": "nri-prelude-0.6.1.2-inplace-tests", + "package": "nri-prelude-0.7.0.0-inplace-tests", "startCol": 22, "startLine": 489 }, diff --git a/nri-prelude/tests/golden-results-9.10/warn b/nri-prelude/tests/golden-results-9.10/warn index aaf7b246..43d7930e 100644 --- a/nri-prelude/tests/golden-results-9.10/warn +++ b/nri-prelude/tests/golden-results-9.10/warn @@ -6,7 +6,7 @@ Just ( "warn" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 53 diff --git a/nri-prelude/tests/golden-results-9.12/debug b/nri-prelude/tests/golden-results-9.12/debug index a2629768..6b3bcde9 100644 --- a/nri-prelude/tests/golden-results-9.12/debug +++ b/nri-prelude/tests/golden-results-9.12/debug @@ -6,7 +6,7 @@ Just ( "debug" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 40 diff --git a/nri-prelude/tests/golden-results-9.12/error b/nri-prelude/tests/golden-results-9.12/error index 4b550b43..0ccd5662 100644 --- a/nri-prelude/tests/golden-results-9.12/error +++ b/nri-prelude/tests/golden-results-9.12/error @@ -6,7 +6,7 @@ Just ( "error" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 66 diff --git a/nri-prelude/tests/golden-results-9.12/log-async-exceptions b/nri-prelude/tests/golden-results-9.12/log-async-exceptions index 9960f182..fb749c61 100644 --- a/nri-prelude/tests/golden-results-9.12/log-async-exceptions +++ b/nri-prelude/tests/golden-results-9.12/log-async-exceptions @@ -6,7 +6,7 @@ Just ( "withContext" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 113 @@ -29,7 +29,7 @@ Just ( "withContext" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 112 diff --git a/nri-prelude/tests/golden-results-9.12/log-info b/nri-prelude/tests/golden-results-9.12/log-info index 9bc7042a..2d8e3184 100644 --- a/nri-prelude/tests/golden-results-9.12/log-info +++ b/nri-prelude/tests/golden-results-9.12/log-info @@ -6,7 +6,7 @@ Just ( "info" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 30 diff --git a/nri-prelude/tests/golden-results-9.12/log-nested-spans b/nri-prelude/tests/golden-results-9.12/log-nested-spans index 5bee1860..9a2ea38d 100644 --- a/nri-prelude/tests/golden-results-9.12/log-nested-spans +++ b/nri-prelude/tests/golden-results-9.12/log-nested-spans @@ -6,7 +6,7 @@ Just ( "withContext" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 81 @@ -29,7 +29,7 @@ Just ( "withContext" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 80 @@ -52,7 +52,7 @@ Just ( "info" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 79 diff --git a/nri-prelude/tests/golden-results-9.12/log-new-root b/nri-prelude/tests/golden-results-9.12/log-new-root index 0547be4d..f08dcf87 100644 --- a/nri-prelude/tests/golden-results-9.12/log-new-root +++ b/nri-prelude/tests/golden-results-9.12/log-new-root @@ -6,7 +6,7 @@ Just ( "info" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 174 @@ -30,7 +30,7 @@ Just ( "info" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 177 @@ -54,7 +54,7 @@ Just ( "info" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 176 @@ -78,7 +78,7 @@ Just ( "withContext" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 173 @@ -101,7 +101,7 @@ Just ( "info" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 173 diff --git a/nri-prelude/tests/golden-results-9.12/log-unexpected-exceptions b/nri-prelude/tests/golden-results-9.12/log-unexpected-exceptions index 94fa4f8f..9deeb593 100644 --- a/nri-prelude/tests/golden-results-9.12/log-unexpected-exceptions +++ b/nri-prelude/tests/golden-results-9.12/log-unexpected-exceptions @@ -6,7 +6,7 @@ Just ( "withContext" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 94 @@ -29,7 +29,7 @@ Just ( "withContext" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 93 diff --git a/nri-prelude/tests/golden-results-9.12/test-report-logfile-all-passed b/nri-prelude/tests/golden-results-9.12/test-report-logfile-all-passed index 085c55b8..d752330c 100644 --- a/nri-prelude/tests/golden-results-9.12/test-report-logfile-all-passed +++ b/nri-prelude/tests/golden-results-9.12/test-report-logfile-all-passed @@ -61,7 +61,7 @@ "file": "tests/TestSpec.hs", "module": "TestSpec", "name": "report", - "package": "nri-prelude-0.6.1.2-inplace-tests", + "package": "nri-prelude-0.7.0.0-inplace-tests", "startCol": 22, "startLine": 430 }, diff --git a/nri-prelude/tests/golden-results-9.12/test-report-logfile-no-tests-in-suite b/nri-prelude/tests/golden-results-9.12/test-report-logfile-no-tests-in-suite index 4fb70627..214949fd 100644 --- a/nri-prelude/tests/golden-results-9.12/test-report-logfile-no-tests-in-suite +++ b/nri-prelude/tests/golden-results-9.12/test-report-logfile-no-tests-in-suite @@ -10,7 +10,7 @@ "file": "tests/TestSpec.hs", "module": "TestSpec", "name": "report", - "package": "nri-prelude-0.6.1.2-inplace-tests", + "package": "nri-prelude-0.7.0.0-inplace-tests", "startCol": 22, "startLine": 469 }, diff --git a/nri-prelude/tests/golden-results-9.12/test-report-logfile-onlys-passed b/nri-prelude/tests/golden-results-9.12/test-report-logfile-onlys-passed index 9025a273..d4b4788c 100644 --- a/nri-prelude/tests/golden-results-9.12/test-report-logfile-onlys-passed +++ b/nri-prelude/tests/golden-results-9.12/test-report-logfile-onlys-passed @@ -61,7 +61,7 @@ "file": "tests/TestSpec.hs", "module": "TestSpec", "name": "report", - "package": "nri-prelude-0.6.1.2-inplace-tests", + "package": "nri-prelude-0.7.0.0-inplace-tests", "startCol": 22, "startLine": 445 }, diff --git a/nri-prelude/tests/golden-results-9.12/test-report-logfile-passed-with-skipped b/nri-prelude/tests/golden-results-9.12/test-report-logfile-passed-with-skipped index 4d09310e..674a9cf4 100644 --- a/nri-prelude/tests/golden-results-9.12/test-report-logfile-passed-with-skipped +++ b/nri-prelude/tests/golden-results-9.12/test-report-logfile-passed-with-skipped @@ -61,7 +61,7 @@ "file": "tests/TestSpec.hs", "module": "TestSpec", "name": "report", - "package": "nri-prelude-0.6.1.2-inplace-tests", + "package": "nri-prelude-0.7.0.0-inplace-tests", "startCol": 22, "startLine": 460 }, diff --git a/nri-prelude/tests/golden-results-9.12/test-report-logfile-tests-failed b/nri-prelude/tests/golden-results-9.12/test-report-logfile-tests-failed index 20a66ae9..e622f4ff 100644 --- a/nri-prelude/tests/golden-results-9.12/test-report-logfile-tests-failed +++ b/nri-prelude/tests/golden-results-9.12/test-report-logfile-tests-failed @@ -109,7 +109,7 @@ "file": "tests/TestSpec.hs", "module": "TestSpec", "name": "report", - "package": "nri-prelude-0.6.1.2-inplace-tests", + "package": "nri-prelude-0.7.0.0-inplace-tests", "startCol": 22, "startLine": 489 }, diff --git a/nri-prelude/tests/golden-results-9.12/warn b/nri-prelude/tests/golden-results-9.12/warn index aaf7b246..43d7930e 100644 --- a/nri-prelude/tests/golden-results-9.12/warn +++ b/nri-prelude/tests/golden-results-9.12/warn @@ -6,7 +6,7 @@ Just ( "warn" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 53 diff --git a/nri-prelude/tests/golden-results-9.8/debug b/nri-prelude/tests/golden-results-9.8/debug index a2629768..6b3bcde9 100644 --- a/nri-prelude/tests/golden-results-9.8/debug +++ b/nri-prelude/tests/golden-results-9.8/debug @@ -6,7 +6,7 @@ Just ( "debug" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 40 diff --git a/nri-prelude/tests/golden-results-9.8/debug-todo-stacktrace b/nri-prelude/tests/golden-results-9.8/debug-todo-stacktrace index c493c9d1..b3913453 100644 --- a/nri-prelude/tests/golden-results-9.8/debug-todo-stacktrace +++ b/nri-prelude/tests/golden-results-9.8/debug-todo-stacktrace @@ -1,3 +1,3 @@ foo CallStack (from HasCallStack): - todo, called at tests/TestSpec.hs:177:20 in nri-prelude-0.6.1.2-inplace-tests:TestSpec \ No newline at end of file + todo, called at tests/TestSpec.hs:177:20 in nri-prelude-0.7.0.0-inplace-tests:TestSpec \ No newline at end of file diff --git a/nri-prelude/tests/golden-results-9.8/error b/nri-prelude/tests/golden-results-9.8/error index 4b550b43..0ccd5662 100644 --- a/nri-prelude/tests/golden-results-9.8/error +++ b/nri-prelude/tests/golden-results-9.8/error @@ -6,7 +6,7 @@ Just ( "error" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 66 diff --git a/nri-prelude/tests/golden-results-9.8/log-async-exceptions b/nri-prelude/tests/golden-results-9.8/log-async-exceptions index 9960f182..fb749c61 100644 --- a/nri-prelude/tests/golden-results-9.8/log-async-exceptions +++ b/nri-prelude/tests/golden-results-9.8/log-async-exceptions @@ -6,7 +6,7 @@ Just ( "withContext" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 113 @@ -29,7 +29,7 @@ Just ( "withContext" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 112 diff --git a/nri-prelude/tests/golden-results-9.8/log-info b/nri-prelude/tests/golden-results-9.8/log-info index 9bc7042a..2d8e3184 100644 --- a/nri-prelude/tests/golden-results-9.8/log-info +++ b/nri-prelude/tests/golden-results-9.8/log-info @@ -6,7 +6,7 @@ Just ( "info" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 30 diff --git a/nri-prelude/tests/golden-results-9.8/log-nested-spans b/nri-prelude/tests/golden-results-9.8/log-nested-spans index 5bee1860..9a2ea38d 100644 --- a/nri-prelude/tests/golden-results-9.8/log-nested-spans +++ b/nri-prelude/tests/golden-results-9.8/log-nested-spans @@ -6,7 +6,7 @@ Just ( "withContext" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 81 @@ -29,7 +29,7 @@ Just ( "withContext" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 80 @@ -52,7 +52,7 @@ Just ( "info" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 79 diff --git a/nri-prelude/tests/golden-results-9.8/log-new-root b/nri-prelude/tests/golden-results-9.8/log-new-root index 0547be4d..f08dcf87 100644 --- a/nri-prelude/tests/golden-results-9.8/log-new-root +++ b/nri-prelude/tests/golden-results-9.8/log-new-root @@ -6,7 +6,7 @@ Just ( "info" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 174 @@ -30,7 +30,7 @@ Just ( "info" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 177 @@ -54,7 +54,7 @@ Just ( "info" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 176 @@ -78,7 +78,7 @@ Just ( "withContext" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 173 @@ -101,7 +101,7 @@ Just ( "info" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 173 diff --git a/nri-prelude/tests/golden-results-9.8/log-unexpected-exceptions b/nri-prelude/tests/golden-results-9.8/log-unexpected-exceptions index 94fa4f8f..9deeb593 100644 --- a/nri-prelude/tests/golden-results-9.8/log-unexpected-exceptions +++ b/nri-prelude/tests/golden-results-9.8/log-unexpected-exceptions @@ -6,7 +6,7 @@ Just ( "withContext" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 94 @@ -29,7 +29,7 @@ Just ( "withContext" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 93 diff --git a/nri-prelude/tests/golden-results-9.8/test-report-logfile-all-passed b/nri-prelude/tests/golden-results-9.8/test-report-logfile-all-passed index 085c55b8..d752330c 100644 --- a/nri-prelude/tests/golden-results-9.8/test-report-logfile-all-passed +++ b/nri-prelude/tests/golden-results-9.8/test-report-logfile-all-passed @@ -61,7 +61,7 @@ "file": "tests/TestSpec.hs", "module": "TestSpec", "name": "report", - "package": "nri-prelude-0.6.1.2-inplace-tests", + "package": "nri-prelude-0.7.0.0-inplace-tests", "startCol": 22, "startLine": 430 }, diff --git a/nri-prelude/tests/golden-results-9.8/test-report-logfile-no-tests-in-suite b/nri-prelude/tests/golden-results-9.8/test-report-logfile-no-tests-in-suite index 4fb70627..214949fd 100644 --- a/nri-prelude/tests/golden-results-9.8/test-report-logfile-no-tests-in-suite +++ b/nri-prelude/tests/golden-results-9.8/test-report-logfile-no-tests-in-suite @@ -10,7 +10,7 @@ "file": "tests/TestSpec.hs", "module": "TestSpec", "name": "report", - "package": "nri-prelude-0.6.1.2-inplace-tests", + "package": "nri-prelude-0.7.0.0-inplace-tests", "startCol": 22, "startLine": 469 }, diff --git a/nri-prelude/tests/golden-results-9.8/test-report-logfile-onlys-passed b/nri-prelude/tests/golden-results-9.8/test-report-logfile-onlys-passed index 9025a273..d4b4788c 100644 --- a/nri-prelude/tests/golden-results-9.8/test-report-logfile-onlys-passed +++ b/nri-prelude/tests/golden-results-9.8/test-report-logfile-onlys-passed @@ -61,7 +61,7 @@ "file": "tests/TestSpec.hs", "module": "TestSpec", "name": "report", - "package": "nri-prelude-0.6.1.2-inplace-tests", + "package": "nri-prelude-0.7.0.0-inplace-tests", "startCol": 22, "startLine": 445 }, diff --git a/nri-prelude/tests/golden-results-9.8/test-report-logfile-passed-with-skipped b/nri-prelude/tests/golden-results-9.8/test-report-logfile-passed-with-skipped index 4d09310e..674a9cf4 100644 --- a/nri-prelude/tests/golden-results-9.8/test-report-logfile-passed-with-skipped +++ b/nri-prelude/tests/golden-results-9.8/test-report-logfile-passed-with-skipped @@ -61,7 +61,7 @@ "file": "tests/TestSpec.hs", "module": "TestSpec", "name": "report", - "package": "nri-prelude-0.6.1.2-inplace-tests", + "package": "nri-prelude-0.7.0.0-inplace-tests", "startCol": 22, "startLine": 460 }, diff --git a/nri-prelude/tests/golden-results-9.8/test-report-logfile-tests-failed b/nri-prelude/tests/golden-results-9.8/test-report-logfile-tests-failed index 20a66ae9..e622f4ff 100644 --- a/nri-prelude/tests/golden-results-9.8/test-report-logfile-tests-failed +++ b/nri-prelude/tests/golden-results-9.8/test-report-logfile-tests-failed @@ -109,7 +109,7 @@ "file": "tests/TestSpec.hs", "module": "TestSpec", "name": "report", - "package": "nri-prelude-0.6.1.2-inplace-tests", + "package": "nri-prelude-0.7.0.0-inplace-tests", "startCol": 22, "startLine": 489 }, diff --git a/nri-prelude/tests/golden-results-9.8/warn b/nri-prelude/tests/golden-results-9.8/warn index aaf7b246..43d7930e 100644 --- a/nri-prelude/tests/golden-results-9.8/warn +++ b/nri-prelude/tests/golden-results-9.8/warn @@ -6,7 +6,7 @@ Just ( "warn" , SrcLoc - { srcLocPackage = "nri-prelude-0.6.1.2-inplace-tests" + { srcLocPackage = "nri-prelude-0.7.0.0-inplace-tests" , srcLocModule = "LogSpec" , srcLocFile = "tests/LogSpec.hs" , srcLocStartLine = 53 diff --git a/nri-redis/nri-redis.cabal b/nri-redis/nri-redis.cabal index 8d358c1c..6a3d72f3 100644 --- a/nri-redis/nri-redis.cabal +++ b/nri-redis/nri-redis.cabal @@ -76,7 +76,7 @@ library , modern-uri >=0.3.1.0 && <0.4 , nri-env-parser >=0.1.0.0 && <0.5 , nri-observability >=0.1.0 && <0.5 - , nri-prelude >=0.1.0.0 && <0.7 + , nri-prelude >=0.1.0.0 && <0.8 , pcre-light >=0.4.1.0 && <0.4.2 , resourcet >=1.2.0 && <1.4 , safe-exceptions >=0.1.7.0 && <1.3 @@ -141,7 +141,7 @@ test-suite tests , modern-uri >=0.3.1.0 && <0.4 , nri-env-parser >=0.1.0.0 && <0.5 , nri-observability >=0.1.0 && <0.5 - , nri-prelude >=0.1.0.0 && <0.7 + , nri-prelude >=0.1.0.0 && <0.8 , pcre-light >=0.4.1.0 && <0.4.2 , resourcet >=1.2.0 && <1.4 , safe-exceptions >=0.1.7.0 && <1.3 diff --git a/nri-redis/package.yaml b/nri-redis/package.yaml index 4edb35e4..568ca854 100644 --- a/nri-redis/package.yaml +++ b/nri-redis/package.yaml @@ -28,7 +28,7 @@ dependencies: - modern-uri >= 0.3.1.0 && < 0.4 - nri-env-parser >= 0.1.0.0 && < 0.5 - nri-observability >= 0.1.0 && < 0.5 - - nri-prelude >= 0.1.0.0 && < 0.7 + - nri-prelude >= 0.1.0.0 && < 0.8 - pcre-light >= 0.4.1.0 && < 0.4.2 - resourcet >= 1.2.0 && < 1.4 - safe-exceptions >= 0.1.7.0 && < 1.3 diff --git a/nri-test-encoding/nri-test-encoding.cabal b/nri-test-encoding/nri-test-encoding.cabal index dc1b25a3..3a3f710d 100644 --- a/nri-test-encoding/nri-test-encoding.cabal +++ b/nri-test-encoding/nri-test-encoding.cabal @@ -58,7 +58,7 @@ library , base >=4.18 && <4.22 , bytestring >=0.10.8.2 && <0.13 , filepath >=1.4.2.1 && <1.6 - , nri-prelude >=0.1.0.0 && <0.7 + , nri-prelude >=0.1.0.0 && <0.8 , nri-redis >=0.1.0.0 && <0.5 , servant >=0.20.2 && <0.21 , servant-auth-server >=0.4.9.0 && <0.5 @@ -99,7 +99,7 @@ test-suite tests , base >=4.18 && <4.22 , bytestring >=0.10.8.2 && <0.13 , filepath >=1.4.2.1 && <1.6 - , nri-prelude >=0.1.0.0 && <0.7 + , nri-prelude >=0.1.0.0 && <0.8 , nri-redis >=0.1.0.0 && <0.5 , servant >=0.20.2 && <0.21 , servant-auth-server >=0.4.9.0 && <0.5 diff --git a/nri-test-encoding/package.yaml b/nri-test-encoding/package.yaml index 3278281a..89d64d3b 100644 --- a/nri-test-encoding/package.yaml +++ b/nri-test-encoding/package.yaml @@ -23,7 +23,7 @@ dependencies: - servant-auth-server >= 0.4.9.0 && < 0.5 - servant-server >= 0.20.2 && < 0.21 - text >= 1.2.3.1 && < 2.2 - - nri-prelude >= 0.1.0.0 && < 0.7 + - nri-prelude >= 0.1.0.0 && < 0.8 - nri-redis >= 0.1.0.0 && < 0.5 library: exposed-modules: From 7a07a7d965274825840806937bf2c57d0d51969d Mon Sep 17 00:00:00 2001 From: Juliano Solanho Date: Thu, 7 May 2026 18:58:39 -0300 Subject: [PATCH 3/5] nri-analytics: scaffold package with sync HTTP delivery Initial nri-analytics package. Provides a Settings type read from ANALYTICS_EVENTS_* env vars, an AnalyticsHandler with a sendEvent callback that POSTs application/json to {eventsServiceUrl}/events with bearer auth and a bounded timeout, and a silentHandler for tests and non-tracking platforms. The handler is sync (one outbound request per track call) and log-and-drops on failure so analytics outages never affect the surrounding request. Wire format is provisional pending the events service contract. --- cabal.project | 1 + nri-analytics/CHANGELOG.md | 7 ++ nri-analytics/LICENSE | 29 +++++++ nri-analytics/README.md | 8 ++ nri-analytics/nri-analytics.cabal | 95 ++++++++++++++++++++++ nri-analytics/package.yaml | 61 ++++++++++++++ nri-analytics/src/Analytics.hs | 101 ++++++++++++++++++++++++ nri-analytics/src/Analytics/Internal.hs | 70 ++++++++++++++++ nri-analytics/tests/Main.hs | 15 ++++ nri-analytics/tests/Spec/Analytics.hs | 52 ++++++++++++ 10 files changed, 439 insertions(+) create mode 100644 nri-analytics/CHANGELOG.md create mode 100644 nri-analytics/LICENSE create mode 100644 nri-analytics/README.md create mode 100644 nri-analytics/nri-analytics.cabal create mode 100644 nri-analytics/package.yaml create mode 100644 nri-analytics/src/Analytics.hs create mode 100644 nri-analytics/src/Analytics/Internal.hs create mode 100644 nri-analytics/tests/Main.hs create mode 100644 nri-analytics/tests/Spec/Analytics.hs diff --git a/cabal.project b/cabal.project index b441e019..f4082476 100644 --- a/cabal.project +++ b/cabal.project @@ -8,3 +8,4 @@ packages: nri-http/nri-http.cabal nri-postgresql/nri-postgresql.cabal nri-kafka/nri-kafka.cabal + nri-analytics/nri-analytics.cabal diff --git a/nri-analytics/CHANGELOG.md b/nri-analytics/CHANGELOG.md new file mode 100644 index 00000000..b8a470df --- /dev/null +++ b/nri-analytics/CHANGELOG.md @@ -0,0 +1,7 @@ +# 0.1.0.0 + +- Initial release. Provides `Analytics.handler`, `Analytics.silentHandler`, + `Analytics.Settings`, and `Analytics.decoder`. Sync HTTP POST delivery + to a configured events service URL with bearer auth, bounded timeout, + and log-and-drop on failure. Wire format is provisional pending the + events service contract. diff --git a/nri-analytics/LICENSE b/nri-analytics/LICENSE new file mode 100644 index 00000000..bd63ae7f --- /dev/null +++ b/nri-analytics/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2024, 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. diff --git a/nri-analytics/README.md b/nri-analytics/README.md new file mode 100644 index 00000000..7a1ed537 --- /dev/null +++ b/nri-analytics/README.md @@ -0,0 +1,8 @@ +# nri-analytics + +Sync HTTP delivery of typed analytics events to the NoRedInk events service. + +This package is consumed by NoRedInk's monolith. It produces an +`AnalyticsHandler` whose `sendEvent` is wired into `nri-prelude`'s +`Platform.Analytics.Internal` callback at the application root. See the +companion design doc in `NoRedInk/event-platform/docs`. diff --git a/nri-analytics/nri-analytics.cabal b/nri-analytics/nri-analytics.cabal new file mode 100644 index 00000000..cab0bf4b --- /dev/null +++ b/nri-analytics/nri-analytics.cabal @@ -0,0 +1,95 @@ +cabal-version: 1.18 + +-- This file has been generated from package.yaml by hpack version 0.37.0. +-- +-- see: https://github.com/sol/hpack + +name: nri-analytics +version: 0.1.0.0 +synopsis: Deliver typed analytics events to the NoRedInk events service. +description: Please see the README at . +category: Web +homepage: https://github.com/NoRedInk/haskell-libraries/tree/trunk/nri-analytics#readme +bug-reports: https://github.com/NoRedInk/haskell-libraries/issues +author: NoRedInk +maintainer: haskell-open-source@noredink.com +copyright: 2026 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: nri-analytics + +library + exposed-modules: + Analytics + other-modules: + Analytics.Internal + hs-source-dirs: + src + default-extensions: + DeriveGeneric + FlexibleInstances + GeneralizedNewtypeDeriving + NamedFieldPuns + NoImplicitPrelude + OverloadedStrings + PartialTypeSignatures + ScopedTypeVariables + Strict + ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wpartial-fields -Wredundant-constraints -Wincomplete-uni-patterns -fplugin=NriPrelude.Plugin + build-depends: + aeson >=2.0 && <2.3 + , base >=4.18 && <4.22 + , bytestring >=0.10.8.2 && <0.13 + , conduit >=1.3.0 && <1.4 + , http-client >=0.6.0 && <0.8 + , http-client-tls >=0.3.0 && <0.4 + , nri-env-parser >=0.1.0.0 && <0.5 + , nri-prelude >=0.7.0.0 && <0.8 + , safe-exceptions >=0.1.7.0 && <1.3 + , text >=1.2.3.1 && <2.2 + default-language: Haskell2010 + +test-suite tests + type: exitcode-stdio-1.0 + main-is: Main.hs + other-modules: + Spec.Analytics + Analytics + Analytics.Internal + Paths_nri_analytics + hs-source-dirs: + tests + src + default-extensions: + DeriveGeneric + FlexibleInstances + GeneralizedNewtypeDeriving + NamedFieldPuns + NoImplicitPrelude + OverloadedStrings + PartialTypeSignatures + ScopedTypeVariables + Strict + ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wpartial-fields -Wredundant-constraints -Wincomplete-uni-patterns -fplugin=NriPrelude.Plugin -threaded + build-depends: + aeson >=2.0 && <2.3 + , base >=4.18 && <4.22 + , bytestring >=0.10.8.2 && <0.13 + , conduit >=1.3.0 && <1.4 + , http-client >=0.6.0 && <0.8 + , http-client-tls >=0.3.0 && <0.4 + , nri-analytics + , nri-env-parser >=0.1.0.0 && <0.5 + , nri-prelude >=0.7.0.0 && <0.8 + , safe-exceptions >=0.1.7.0 && <1.3 + , text >=1.2.3.1 && <2.2 + default-language: Haskell2010 diff --git a/nri-analytics/package.yaml b/nri-analytics/package.yaml new file mode 100644 index 00000000..d5bb8fd1 --- /dev/null +++ b/nri-analytics/package.yaml @@ -0,0 +1,61 @@ +name: nri-analytics +synopsis: Deliver typed analytics events to the NoRedInk events service. +description: Please see the README at . +homepage: https://github.com/NoRedInk/haskell-libraries/tree/trunk/nri-analytics#readme +author: NoRedInk +version: 0.1.0.0 +maintainer: haskell-open-source@noredink.com +copyright: 2026 NoRedInk Corp. +github: NoRedInk/haskell-libraries/nri-analytics +license-file: LICENSE +category: Web +extra-doc-files: + - README.md + - LICENSE + - CHANGELOG.md +dependencies: + - aeson >= 2.0 && < 2.3 + - base >= 4.18 && < 4.22 + - bytestring >= 0.10.8.2 && < 0.13 + - conduit >= 1.3.0 && < 1.4 + - http-client >= 0.6.0 && < 0.8 + - http-client-tls >= 0.3.0 && < 0.4 + - nri-env-parser >= 0.1.0.0 && < 0.5 + - nri-prelude >= 0.7.0.0 && < 0.8 + - safe-exceptions >= 0.1.7.0 && < 1.3 + - text >= 1.2.3.1 && < 2.2 +library: + exposed-modules: + - Analytics + other-modules: + - Analytics.Internal + source-dirs: src +default-extensions: + - DeriveGeneric + - FlexibleInstances + - GeneralizedNewtypeDeriving + - NamedFieldPuns + - NoImplicitPrelude + - OverloadedStrings + - PartialTypeSignatures + - ScopedTypeVariables + - Strict +ghc-options: + - -Wall + - -Wcompat + - -Widentities + - -Wincomplete-record-updates + - -Wpartial-fields + - -Wredundant-constraints + - -Wincomplete-uni-patterns + - -fplugin=NriPrelude.Plugin +tests: + tests: + main: Main.hs + source-dirs: + - tests + - src + ghc-options: + - -threaded + dependencies: + - nri-analytics diff --git a/nri-analytics/src/Analytics.hs b/nri-analytics/src/Analytics.hs new file mode 100644 index 00000000..02044f8e --- /dev/null +++ b/nri-analytics/src/Analytics.hs @@ -0,0 +1,101 @@ +-- | Sync HTTP delivery of typed analytics events to NoRedInk's events +-- service. The `AnalyticsHandler` produced here is meant to be threaded +-- into `nri-prelude`'s `LogHandler` analytics callback at the +-- application root. +module Analytics + ( -- * Handle + Internal.AnalyticsHandler, + sendEvent, + handler, + silentHandler, + + -- * Settings + Internal.Settings + ( Settings, + eventsServiceUrl, + timeoutMicros, + authToken + ), + decoder, + ) +where + +import qualified Analytics.Internal as Internal +import qualified Conduit +import qualified Data.Aeson as Aeson +import qualified Environment +import qualified Network.HTTP.Client.TLS as HTTP.TLS +import NriPrelude +import Prelude (IO, pure) + +-- | Send a single event payload. Called from +-- `Platform.Analytics.Internal.trackEvent` via the `LogHandler`'s +-- analytics callback. Synchronous; bounded by `Settings.timeoutMicros`; +-- never throws. +sendEvent :: Internal.AnalyticsHandler -> Aeson.Value -> IO () +sendEvent = Internal.sendEvent + +-- | A no-op handler. Use this in tests and on platforms that have not +-- opted in to analytics tracking yet. +silentHandler :: Internal.AnalyticsHandler +silentHandler = + Internal.AnalyticsHandler + { Internal.sendEvent = \_ -> pure (), + Internal.settings = + Internal.Settings + { Internal.eventsServiceUrl = "", + Internal.timeoutMicros = 0, + Internal.authToken = "" + }, + Internal.httpManager = Nothing + } + +-- | Acquire a live handler. Owns an HTTP manager that lives until the +-- `Acquire` is released. +handler :: Internal.Settings -> Conduit.Acquire Internal.AnalyticsHandler +handler s = do + manager <- Conduit.mkAcquire HTTP.TLS.newTlsManager (\_ -> pure ()) + pure + Internal.AnalyticsHandler + { Internal.sendEvent = Internal.sendEventIO manager s, + Internal.settings = s, + Internal.httpManager = Just manager + } + +-- | Read settings from environment variables. +decoder :: Environment.Decoder Internal.Settings +decoder = + pure Internal.Settings + |> andMap eventsServiceUrlDecoder + |> andMap timeoutMicrosDecoder + |> andMap authTokenDecoder + +eventsServiceUrlDecoder :: Environment.Decoder Text +eventsServiceUrlDecoder = + Environment.variable + Environment.Variable + { Environment.name = "ANALYTICS_EVENTS_SERVICE_URL", + Environment.description = "Base URL of the events service.", + Environment.defaultValue = "" + } + Environment.text + +timeoutMicrosDecoder :: Environment.Decoder Int +timeoutMicrosDecoder = + Environment.variable + Environment.Variable + { Environment.name = "ANALYTICS_EVENTS_TIMEOUT_MICROS", + Environment.description = "Per-request timeout in microseconds. Default 500000 (500ms).", + Environment.defaultValue = "500000" + } + Environment.int + +authTokenDecoder :: Environment.Decoder Text +authTokenDecoder = + Environment.variable + Environment.Variable + { Environment.name = "ANALYTICS_EVENTS_AUTH_TOKEN", + Environment.description = "Bearer token for the events service.", + Environment.defaultValue = "" + } + Environment.text diff --git a/nri-analytics/src/Analytics/Internal.hs b/nri-analytics/src/Analytics/Internal.hs new file mode 100644 index 00000000..6222c59f --- /dev/null +++ b/nri-analytics/src/Analytics/Internal.hs @@ -0,0 +1,70 @@ +module Analytics.Internal + ( AnalyticsHandler (..), + Settings (..), + buildRequest, + sendEventIO, + ) +where + +import qualified Control.Exception.Safe as Exception +import qualified Data.Aeson as Aeson +import qualified Data.Text +import qualified Data.Text.Encoding +import qualified Network.HTTP.Client as HTTP +import NriPrelude +import qualified Prelude +import Prelude (IO, pure) + +-- | Configuration for the analytics handler. Loaded from environment +-- variables via `Analytics.decoder`. The events service contract is +-- provisional; revise these fields as the contract solidifies. +data Settings = Settings + { -- | Base URL of the events service, e.g. https://events.noredink.com. + eventsServiceUrl :: Text, + -- | Per-request HTTP timeout. The request thread blocks for at most + -- this long before the event is dropped. + timeoutMicros :: Int, + -- | Bearer token used in the Authorization header. + authToken :: Text + } + +-- | Live handle for delivering events. The `sendEvent` callback is what +-- gets threaded into nri-prelude's `LogHandler`. The other fields are +-- internal but must be retained to keep the HTTP manager + counters +-- alive for the program's lifetime. +data AnalyticsHandler = AnalyticsHandler + { sendEvent :: Aeson.Value -> IO (), + settings :: Settings, + httpManager :: Maybe HTTP.Manager + } + +-- | Build the HTTP request for a single event. Pure-ish: doesn't fire the +-- request, just constructs it. Tested directly so we don't have to spin +-- up a server. +buildRequest :: Settings -> Aeson.Value -> IO HTTP.Request +buildRequest s value = do + initial <- HTTP.parseRequest (Data.Text.unpack (eventsServiceUrl s ++ "/events")) + pure + initial + { HTTP.method = "POST", + HTTP.requestHeaders = + [ ("Content-Type", "application/json"), + ("Authorization", "Bearer " ++ Data.Text.Encoding.encodeUtf8 (authToken s)) + ], + HTTP.requestBody = HTTP.RequestBodyLBS (Aeson.encode value), + HTTP.responseTimeout = HTTP.responseTimeoutMicro (Prelude.fromIntegral (timeoutMicros s)) + } + +-- | Synchronous HTTP delivery. Catches every exception and drops the +-- event so analytics failure can never bring down the surrounding +-- request. (Logging hook is a TODO until the prelude logger is wired +-- through here.) +sendEventIO :: HTTP.Manager -> Settings -> Aeson.Value -> IO () +sendEventIO manager s value = + Exception.handleAny logAndDrop <| do + request <- buildRequest s value + _response <- HTTP.httpLbs request manager + pure () + where + logAndDrop :: Exception.SomeException -> IO () + logAndDrop _e = pure () diff --git a/nri-analytics/tests/Main.hs b/nri-analytics/tests/Main.hs new file mode 100644 index 00000000..8d26936d --- /dev/null +++ b/nri-analytics/tests/Main.hs @@ -0,0 +1,15 @@ +module Main (main) where + +import qualified Spec.Analytics +import qualified Test +import qualified Prelude + +main :: Prelude.IO () +main = Test.run tests + +tests :: Test.Test +tests = + Test.describe + "nri-analytics" + [ Spec.Analytics.tests + ] diff --git a/nri-analytics/tests/Spec/Analytics.hs b/nri-analytics/tests/Spec/Analytics.hs new file mode 100644 index 00000000..d9f8d2b1 --- /dev/null +++ b/nri-analytics/tests/Spec/Analytics.hs @@ -0,0 +1,52 @@ +module Spec.Analytics (tests) where + +import qualified Analytics +import qualified Analytics.Internal as Internal +import qualified Data.Aeson as Aeson +import qualified Data.Text +import qualified Dict +import qualified Environment +import qualified Expect +import qualified Network.HTTP.Client as HTTP +import NriPrelude +import Test (Test, describe, test) +import qualified Prelude + +tests :: Test +tests = + describe + "Analytics" + [ test "silentHandler.sendEvent does nothing and returns ()" <| \_ -> do + Expect.fromIO <| Analytics.sendEvent Analytics.silentHandler (Aeson.object []), + test "buildRequest sets URL, method, content-type, bearer auth" <| \_ -> do + let settings = + Internal.Settings + { Internal.eventsServiceUrl = "https://events.example.com", + Internal.timeoutMicros = 250000, + Internal.authToken = "secret-token" + } + req <- Expect.fromIO <| Internal.buildRequest settings (Aeson.object []) + let headers = HTTP.requestHeaders req + HTTP.method req + |> Expect.equal "POST" + Prelude.lookup "Content-Type" headers + |> Expect.equal (Just "application/json") + Prelude.lookup "Authorization" headers + |> Expect.equal (Just "Bearer secret-token"), + test "decoder loads settings from env vars" <| \_ -> do + let env = + Dict.fromList + [ ("ANALYTICS_EVENTS_SERVICE_URL", "https://events.example.com"), + ("ANALYTICS_EVENTS_TIMEOUT_MICROS", "300000"), + ("ANALYTICS_EVENTS_AUTH_TOKEN", "the-token") + ] + case Environment.decodePairs Analytics.decoder env of + Ok s -> do + Internal.eventsServiceUrl s + |> Expect.equal "https://events.example.com" + Internal.timeoutMicros s + |> Expect.equal 300000 + Internal.authToken s + |> Expect.equal "the-token" + Err err -> Expect.fail (Data.Text.pack (Prelude.show err)) + ] From ecbf3755cbe38dd0af68aba95df624a871e84feb Mon Sep 17 00:00:00 2001 From: Juliano Solanho Date: Thu, 7 May 2026 19:23:43 -0300 Subject: [PATCH 4/5] nri-analytics: stamp event_id and event_timestamp at delivery The envelope fields required by the events service contract are added by the wire layer at sendEvent time, not by the typed Event in NoRedInk. This keeps the domain Event type pure data and removes the need to lift IO into Task in callers. --- nri-analytics/nri-analytics.cabal | 4 ++++ nri-analytics/package.yaml | 2 ++ nri-analytics/src/Analytics/Internal.hs | 26 ++++++++++++++++++++++++- nri-analytics/tests/Spec/Analytics.hs | 13 +++++++++++++ 4 files changed, 44 insertions(+), 1 deletion(-) diff --git a/nri-analytics/nri-analytics.cabal b/nri-analytics/nri-analytics.cabal index cab0bf4b..8e4ddc73 100644 --- a/nri-analytics/nri-analytics.cabal +++ b/nri-analytics/nri-analytics.cabal @@ -56,6 +56,8 @@ library , nri-prelude >=0.7.0.0 && <0.8 , safe-exceptions >=0.1.7.0 && <1.3 , text >=1.2.3.1 && <2.2 + , time >=1.9.3 && <2 + , uuid ==1.3.* default-language: Haskell2010 test-suite tests @@ -92,4 +94,6 @@ test-suite tests , nri-prelude >=0.7.0.0 && <0.8 , safe-exceptions >=0.1.7.0 && <1.3 , text >=1.2.3.1 && <2.2 + , time >=1.9.3 && <2 + , uuid ==1.3.* default-language: Haskell2010 diff --git a/nri-analytics/package.yaml b/nri-analytics/package.yaml index d5bb8fd1..4705b70f 100644 --- a/nri-analytics/package.yaml +++ b/nri-analytics/package.yaml @@ -24,6 +24,8 @@ dependencies: - nri-prelude >= 0.7.0.0 && < 0.8 - safe-exceptions >= 0.1.7.0 && < 1.3 - text >= 1.2.3.1 && < 2.2 + - time >= 1.9.3 && < 2 + - uuid >= 1.3 && < 1.4 library: exposed-modules: - Analytics diff --git a/nri-analytics/src/Analytics/Internal.hs b/nri-analytics/src/Analytics/Internal.hs index 6222c59f..d7942859 100644 --- a/nri-analytics/src/Analytics/Internal.hs +++ b/nri-analytics/src/Analytics/Internal.hs @@ -3,13 +3,19 @@ module Analytics.Internal Settings (..), buildRequest, sendEventIO, + stampEnvelope, ) where import qualified Control.Exception.Safe as Exception import qualified Data.Aeson as Aeson +import qualified Data.Aeson.KeyMap as KeyMap import qualified Data.Text import qualified Data.Text.Encoding +import qualified Data.Time.Clock as Clock +import qualified Data.Time.Format.ISO8601 as ISO8601 +import qualified Data.UUID +import qualified Data.UUID.V4 as UUID import qualified Network.HTTP.Client as HTTP import NriPrelude import qualified Prelude @@ -62,9 +68,27 @@ buildRequest s value = do sendEventIO :: HTTP.Manager -> Settings -> Aeson.Value -> IO () sendEventIO manager s value = Exception.handleAny logAndDrop <| do - request <- buildRequest s value + enveloped <- stampEnvelope value + request <- buildRequest s enveloped _response <- HTTP.httpLbs request manager pure () where logAndDrop :: Exception.SomeException -> IO () logAndDrop _e = pure () + +-- | Mint event_id (UUID v4) + event_timestamp (ISO 8601 UTC) and +-- shallow-merge them onto the event JSON. Envelope keys win on +-- collision. Non-object inbound values are wrapped under a `payload` +-- key so the envelope still produces a valid object. +stampEnvelope :: Aeson.Value -> IO Aeson.Value +stampEnvelope inbound = do + uuid <- UUID.nextRandom + now <- Clock.getCurrentTime + let envelope = + KeyMap.fromList + [ ("event_id", Aeson.String (Data.UUID.toText uuid)), + ("event_timestamp", Aeson.String (Data.Text.pack (ISO8601.iso8601Show now))) + ] + pure <| case inbound of + Aeson.Object body -> Aeson.Object (KeyMap.union envelope body) + other -> Aeson.Object (KeyMap.insert "payload" other envelope) diff --git a/nri-analytics/tests/Spec/Analytics.hs b/nri-analytics/tests/Spec/Analytics.hs index d9f8d2b1..3b4f8555 100644 --- a/nri-analytics/tests/Spec/Analytics.hs +++ b/nri-analytics/tests/Spec/Analytics.hs @@ -3,6 +3,7 @@ module Spec.Analytics (tests) where import qualified Analytics import qualified Analytics.Internal as Internal import qualified Data.Aeson as Aeson +import qualified Data.Aeson.KeyMap as KeyMap import qualified Data.Text import qualified Dict import qualified Environment @@ -33,6 +34,18 @@ tests = |> Expect.equal (Just "application/json") Prelude.lookup "Authorization" headers |> Expect.equal (Just "Bearer secret-token"), + test "stampEnvelope adds event_id and event_timestamp keys" <| \_ -> do + result <- Expect.fromIO <| Internal.stampEnvelope (Aeson.object [("foo", Aeson.String "bar")]) + case result of + Aeson.Object km -> do + case KeyMap.lookup "event_id" km of + Just (Aeson.String _) -> Expect.pass + _ -> Expect.fail "event_id missing or wrong shape" + case KeyMap.lookup "event_timestamp" km of + Just (Aeson.String _) -> Expect.pass + _ -> Expect.fail "event_timestamp missing or wrong shape" + Expect.equal (KeyMap.lookup "foo" km) (Just (Aeson.String "bar")) + _ -> Expect.fail "expected Object", test "decoder loads settings from env vars" <| \_ -> do let env = Dict.fromList From 0fdd622947c8cace4782a484c8851c61dc8158e5 Mon Sep 17 00:00:00 2001 From: Juliano Solanho Date: Thu, 7 May 2026 19:57:49 -0300 Subject: [PATCH 5/5] nri-analytics: wrap authToken in Log.Secret and add failure-path test The auth token is now Log.Secret Text so accidental Show/logging doesn't leak it. buildRequest unwraps it at the HTTP-header boundary; the decoder uses Environment.secret to wrap on read. Also adds a test that sendEventIO swallows exceptions when delivery fails (unreachable URL), exercising the non-throwing contract that the surrounding request depends on. Co-Authored-By: Claude Opus 4.7 (1M context) --- nri-analytics/src/Analytics.hs | 7 ++++--- nri-analytics/src/Analytics/Internal.hs | 9 ++++++--- nri-analytics/tests/Spec/Analytics.hs | 20 +++++++++++++++++--- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/nri-analytics/src/Analytics.hs b/nri-analytics/src/Analytics.hs index 02044f8e..7b074730 100644 --- a/nri-analytics/src/Analytics.hs +++ b/nri-analytics/src/Analytics.hs @@ -24,6 +24,7 @@ import qualified Analytics.Internal as Internal import qualified Conduit import qualified Data.Aeson as Aeson import qualified Environment +import qualified Log import qualified Network.HTTP.Client.TLS as HTTP.TLS import NriPrelude import Prelude (IO, pure) @@ -45,7 +46,7 @@ silentHandler = Internal.Settings { Internal.eventsServiceUrl = "", Internal.timeoutMicros = 0, - Internal.authToken = "" + Internal.authToken = Log.mkSecret "" }, Internal.httpManager = Nothing } @@ -90,7 +91,7 @@ timeoutMicrosDecoder = } Environment.int -authTokenDecoder :: Environment.Decoder Text +authTokenDecoder :: Environment.Decoder (Log.Secret Text) authTokenDecoder = Environment.variable Environment.Variable @@ -98,4 +99,4 @@ authTokenDecoder = Environment.description = "Bearer token for the events service.", Environment.defaultValue = "" } - Environment.text + (Environment.secret Environment.text) diff --git a/nri-analytics/src/Analytics/Internal.hs b/nri-analytics/src/Analytics/Internal.hs index d7942859..2198c8a0 100644 --- a/nri-analytics/src/Analytics/Internal.hs +++ b/nri-analytics/src/Analytics/Internal.hs @@ -16,6 +16,7 @@ import qualified Data.Time.Clock as Clock import qualified Data.Time.Format.ISO8601 as ISO8601 import qualified Data.UUID import qualified Data.UUID.V4 as UUID +import qualified Log import qualified Network.HTTP.Client as HTTP import NriPrelude import qualified Prelude @@ -30,8 +31,10 @@ data Settings = Settings -- | Per-request HTTP timeout. The request thread blocks for at most -- this long before the event is dropped. timeoutMicros :: Int, - -- | Bearer token used in the Authorization header. - authToken :: Text + -- | Bearer token used in the Authorization header. Wrapped in + -- `Log.Secret` so accidental logging or `Show`-deriving doesn't + -- leak it. + authToken :: Log.Secret Text } -- | Live handle for delivering events. The `sendEvent` callback is what @@ -55,7 +58,7 @@ buildRequest s value = do { HTTP.method = "POST", HTTP.requestHeaders = [ ("Content-Type", "application/json"), - ("Authorization", "Bearer " ++ Data.Text.Encoding.encodeUtf8 (authToken s)) + ("Authorization", "Bearer " ++ Data.Text.Encoding.encodeUtf8 (Log.unSecret (authToken s))) ], HTTP.requestBody = HTTP.RequestBodyLBS (Aeson.encode value), HTTP.responseTimeout = HTTP.responseTimeoutMicro (Prelude.fromIntegral (timeoutMicros s)) diff --git a/nri-analytics/tests/Spec/Analytics.hs b/nri-analytics/tests/Spec/Analytics.hs index 3b4f8555..31b98cdd 100644 --- a/nri-analytics/tests/Spec/Analytics.hs +++ b/nri-analytics/tests/Spec/Analytics.hs @@ -8,7 +8,9 @@ import qualified Data.Text import qualified Dict import qualified Environment import qualified Expect +import qualified Log import qualified Network.HTTP.Client as HTTP +import qualified Network.HTTP.Client.TLS as HTTP.TLS import NriPrelude import Test (Test, describe, test) import qualified Prelude @@ -24,7 +26,7 @@ tests = Internal.Settings { Internal.eventsServiceUrl = "https://events.example.com", Internal.timeoutMicros = 250000, - Internal.authToken = "secret-token" + Internal.authToken = Log.mkSecret "secret-token" } req <- Expect.fromIO <| Internal.buildRequest settings (Aeson.object []) let headers = HTTP.requestHeaders req @@ -59,7 +61,19 @@ tests = |> Expect.equal "https://events.example.com" Internal.timeoutMicros s |> Expect.equal 300000 - Internal.authToken s + Log.unSecret (Internal.authToken s) |> Expect.equal "the-token" - Err err -> Expect.fail (Data.Text.pack (Prelude.show err)) + Err err -> Expect.fail (Data.Text.pack (Prelude.show err)), + test "sendEventIO swallows exceptions when delivery fails" <| \_ -> do + -- Construct a settings pointing at an unreachable URL; verify that + -- sendEventIO catches the resulting exception and returns () so + -- analytics failure cannot propagate into the surrounding request. + manager <- Expect.fromIO HTTP.TLS.newTlsManager + let settings = + Internal.Settings + { Internal.eventsServiceUrl = "http://127.0.0.1:1", + Internal.timeoutMicros = 50000, + Internal.authToken = Log.mkSecret "" + } + Expect.fromIO <| Internal.sendEventIO manager settings (Aeson.object [("k", Aeson.String "v")]) ]