packages feed

effectful-tracing-0.1.0.0: test/Effectful/Tracing/ThunkSpec.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# OPTIONS_GHC -Wno-orphans #-}

-- |
-- Module      : Effectful.Tracing.ThunkSpec
-- Description : Regression guard against thunk retention in completed spans.
--
-- A completed 'Span' crosses into an interpreter's sink (the in-memory buffer,
-- the pretty-print accumulator) and is held there. The shared lifecycle forces
-- that span to WHNF before handing it over (see @finalizeSpan@), so the sink
-- stores a finished value rather than a thunk that retains the span's builder
-- 'Data.IORef.IORef' and the live @ActiveSpan@. These tests assert, with
-- @nothunks@, that the spans a real traced computation produces carry no
-- unexpected thunk.
--
-- The check is deliberately precise rather than a blanket deep check. The
-- result attribute/event/link lists are built with 'reverse' and are
-- intentionally spine-lazy, so a deep walk would report thunks in their tails
-- that are not leaks. We therefore check the strict scalar structure of the
-- span deeply and the container fields to WHNF, which is exactly what the
-- lifecycle guarantees. The @NoThunks@ instances below live here, not in the
-- library, so the published package takes on no @nothunks@ dependency.
module Effectful.Tracing.ThunkSpec
  ( tests
  ) where

import Data.Text (Text)
import GHC.Generics (Generic)

import NoThunks.Class (NoThunks (noThunks, showTypeOf, wNoThunks), OnlyCheckWhnf (OnlyCheckWhnf), allNoThunks)

import Effectful (Eff, IOE, runEff, (:>))
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertFailure, testCase)

import Effectful.Tracing
  ( Tracer
  , addAttribute
  , addAttributes
  , addEvent
  , setStatus
  , withSpan
  , (.=)
  )
import Effectful.Tracing.Internal.Ids (SpanId, TraceId)
import Effectful.Tracing.Internal.Types
  ( Span (..)
  , SpanContext (..)
  , SpanKind (..)
  , SpanStatus (..)
  , TraceFlags
  , TraceState
  )
import Effectful.Tracing.Interpreter.InMemory
  ( newCapturedSpans
  , readCapturedSpans
  , runTracerInMemory
  )

-- The id and flag leaves are strict and opaque; checking them to WHNF is
-- enough (and avoids reaching into @bytestring@ / @text@ internals).
deriving via OnlyCheckWhnf TraceId instance NoThunks TraceId

deriving via OnlyCheckWhnf SpanId instance NoThunks SpanId

deriving via OnlyCheckWhnf TraceFlags instance NoThunks TraceFlags

deriving via OnlyCheckWhnf TraceState instance NoThunks TraceState

deriving stock instance Generic SpanContext

deriving anyclass instance NoThunks SpanContext

deriving stock instance Generic SpanKind

deriving anyclass instance NoThunks SpanKind

deriving stock instance Generic SpanStatus

deriving anyclass instance NoThunks SpanStatus

-- | Check the span itself and its strict scalar fields deeply; check the
-- intentionally spine-lazy container fields (and the timestamps) to WHNF.
--
-- Each field is bound with a bang before being handed to @nothunks@. Record
-- selectors are lazy, so @noThunks ctx (spanContext s)@ would otherwise inspect
-- the /selector application/ itself (an unevaluated thunk) and report a false
-- positive. The fields are strict, so forcing the projection to WHNF is a
-- no-op semantically and leaves any genuine nested thunk below WHNF intact for
-- the deep checks to find.
instance NoThunks Span where
  showTypeOf _ = "Span"
  wNoThunks ctx s = noThunks ctx (OnlyCheckWhnf s)
  noThunks ctx s =
    allNoThunks
      [ noThunks ctx (OnlyCheckWhnf s)
      , noThunks ("spanContext" : ctx) sContext
      , noThunks ("spanParentContext" : ctx) sParent
      , noThunks ("spanName" : ctx) sName
      , noThunks ("spanKind" : ctx) sKind
      , noThunks ("spanStartTime" : ctx) (OnlyCheckWhnf sStart)
      , noThunks ("spanEndTime" : ctx) (OnlyCheckWhnf sEnd)
      , noThunks ("spanAttributes" : ctx) (OnlyCheckWhnf sAttrs)
      , noThunks ("spanEvents" : ctx) (OnlyCheckWhnf sEvents)
      , noThunks ("spanLinks" : ctx) (OnlyCheckWhnf sLinks)
      , noThunks ("spanStatus" : ctx) sStatus
      ]
    where
      !sContext = spanContext s
      !sParent = spanParentContext s
      !sName = spanName s
      !sKind = spanKind s
      !sStart = spanStartTime s
      !sEnd = spanEndTime s
      !sAttrs = spanAttributes s
      !sEvents = spanEvents s
      !sLinks = spanLinks s
      !sStatus = spanStatus s

-- | A traced computation that exercises every span field kind: scalar and array
-- attributes, an event, a status, and a nested child span.
program :: (Tracer :> es) => Eff es ()
program =
  withSpan "root" $ do
    addAttribute "service" ("checkout" :: Text)
    addAttribute "items" (3 :: Int)
    addAttributes
      [ "tags" .= (["fast", "cart"] :: [Text])
      , "codes" .= ([200, 404] :: [Int])
      ]
    addEvent "cache.miss" ["key" .= ("session:42" :: Text)]
    withSpan "child" $ do
      addAttribute "db.rows" (12 :: Int)
      setStatus Ok
    setStatus Ok

tests :: TestTree
tests =
  testGroup
    "Thunk retention"
    [ testCase "completed spans carry no unexpected thunk" $ do
        spans <- captureSpans program
        case spans of
          [] -> assertFailure "expected the traced computation to produce spans"
          _ -> mapM_ assertNoThunks spans
    ]

-- | Run a traced program through the in-memory interpreter and return the
-- captured spans.
captureSpans :: Eff '[Tracer, IOE] a -> IO [Span]
captureSpans p = runEff $ do
  captured <- newCapturedSpans
  _ <- runTracerInMemory captured p
  readCapturedSpans captured

assertNoThunks :: Span -> IO ()
assertNoThunks s = do
  result <- noThunks [] s
  case result of
    Nothing -> pure ()
    Just info -> assertFailure ("unexpected thunk in completed span: " <> show info)