fractal-layer-0.1.0.0: src/Fractal/Layer/Diagnostics.hs
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- |
-- Module : Fractal.Layer.Diagnostics
-- Description : Diagnostics and visualization for layer initialization
-- Stability : experimental
-- Portability : Portable
--
-- This module provides tools for visualizing and debugging layer initialization.
-- It can track how layers are composed, which services are shared, and render
-- the dependency tree in various formats.
--
-- == Example Usage
--
-- @
-- appLayer :: Layer IO () (Config, Database, WebServer)
-- appLayer = configLayer >>> (dbLayer &&& webLayer)
--
-- main :: IO ()
-- main = withLayerDiagnostics appLayer () $ \(env, diags) -> do
-- -- Print ASCII tree
-- putStrLn $ renderLayerTree diags
--
-- -- Or export as JSON
-- BSL.writeFile "layer-tree.json" (encode diags)
--
-- -- Use the environment
-- let (cfg, db, ws) = env
-- serveRequests ws
-- @
module Fractal.Layer.Diagnostics
( -- * Diagnostics Types
LayerDiagnostics (..),
LayerNode (..),
LayerNodeType (..),
ResourceStatus (..),
-- * Diagnostics Interceptor
createDiagnosticsInterceptor,
DiagnosticsCollector,
newDiagnosticsCollector,
finalizeDiagnostics,
-- * Running with Diagnostics
withLayerDiagnostics,
buildLayerDiagnostics,
-- * Rendering
renderLayerTree,
renderLayerTreeDetailed,
-- * Snapshots
snapshotDiagnostics,
-- * JSON Export
diagnosticsToJSON,
)
where
import Control.Monad.IO.Class
import Data.Aeson (ToJSON (..), FromJSON (..), object, (.=), (.:), Value, withObject, withText)
import Data.HashMap.Strict (HashMap)
import qualified Data.HashMap.Strict as HashMap
import Control.Concurrent.MVar
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (NominalDiffTime, getCurrentTime, diffUTCTime, UTCTime)
import Data.Typeable
import Fractal.Layer.Interceptor
import UnliftIO (MonadUnliftIO)
-- Import Layer types and functions (defined later in dependency order)
import Fractal.Layer (Layer, runLayerWithInterceptor, withLayerAndInterceptor)
-------------------------------------------------------------------------------
-- Types
-------------------------------------------------------------------------------
-- | Complete diagnostics for a layer initialization
data LayerDiagnostics = LayerDiagnostics
{ rootNode :: LayerNode
-- ^ The root of the initialization tree
, totalDuration :: Double
-- ^ Total time taken to initialize (seconds)
, totalResources :: Int
-- ^ Total number of resources allocated
, sharedResources :: Int
-- ^ Number of resources that were shared/cached
}
deriving (Show)
-- | A node in the layer initialization tree
data LayerNode = LayerNode
{ nodeId :: Text
-- ^ Unique identifier for this node
, nodeName :: Text
-- ^ Human-readable name (e.g., "DatabaseLayer", "ConfigService")
, nodeType :: LayerNodeType
-- ^ Type of layer node
, resourceType :: Maybe TypeRep
-- ^ The type of resource produced (if available)
, status :: ResourceStatus
-- ^ Status of this resource
, duration :: Maybe Double
-- ^ Time taken to initialize this layer (seconds)
, children :: [LayerNode]
-- ^ Child layers
, metadata :: HashMap Text Text
-- ^ Additional metadata
}
deriving (Show)
-- | The type of layer node
data LayerNodeType
= ResourceNode
-- ^ A resource that was allocated
| EffectNode
-- ^ An effect that was run
| ServiceNode
-- ^ A cached service
| ComposedNode
-- ^ A composed layer (>>> or &&&)
| ParallelNode
-- ^ Parallel composition (&&&)
| SequentialNode
-- ^ Sequential composition (>>>)
deriving (Show, Eq)
-- | Status of a resource
data ResourceStatus
= Initializing
-- ^ Currently being initialized
| Initialized
-- ^ Successfully initialized
| Failed String
-- ^ Failed to initialize with error
| SharedReference Text
-- ^ Reference to a shared resource (with node ID)
deriving (Show, Eq)
-------------------------------------------------------------------------------
-- JSON Instances
-------------------------------------------------------------------------------
instance ToJSON LayerDiagnostics where
toJSON LayerDiagnostics{..} =
object
[ "root" .= rootNode
, "totalDuration" .= totalDuration
, "totalResources" .= totalResources
, "sharedResources" .= sharedResources
]
instance ToJSON LayerNode where
toJSON LayerNode{..} =
object
[ "id" .= nodeId
, "name" .= nodeName
, "type" .= nodeType
, "resourceType" .= (show <$> resourceType)
, "status" .= status
, "duration" .= duration
, "children" .= children
, "metadata" .= metadata
]
instance ToJSON LayerNodeType where
toJSON = \case
ResourceNode -> "resource"
EffectNode -> "effect"
ServiceNode -> "service"
ComposedNode -> "composed"
ParallelNode -> "parallel"
SequentialNode -> "sequential"
instance ToJSON ResourceStatus where
toJSON = \case
Initializing -> object ["status" .= ("initializing" :: Text)]
Initialized -> object ["status" .= ("initialized" :: Text)]
Failed err -> object ["status" .= ("failed" :: Text), "error" .= err]
SharedReference nodeId -> object ["status" .= ("shared" :: Text), "reference" .= nodeId]
-- FromJSON instances for deserialization
instance FromJSON LayerDiagnostics where
parseJSON = withObject "LayerDiagnostics" $ \v -> LayerDiagnostics
<$> v .: "root"
<*> v .: "totalDuration"
<*> v .: "totalResources"
<*> v .: "sharedResources"
instance FromJSON LayerNode where
parseJSON = withObject "LayerNode" $ \v -> LayerNode
<$> v .: "id"
<*> v .: "name"
<*> v .: "type"
<*> pure Nothing -- resourceType is serialized as String, skip for now
<*> v .: "status"
<*> v .: "duration"
<*> v .: "children"
<*> v .: "metadata"
instance FromJSON LayerNodeType where
parseJSON = withText "LayerNodeType" $ \case
"resource" -> pure ResourceNode
"effect" -> pure EffectNode
"service" -> pure ServiceNode
"composed" -> pure ComposedNode
"parallel" -> pure ParallelNode
"sequential" -> pure SequentialNode
_ -> fail "Unknown layer node type"
instance FromJSON ResourceStatus where
parseJSON = withObject "ResourceStatus" $ \v -> do
status <- v .: "status"
case status :: Text of
"initializing" -> pure Initializing
"initialized" -> pure Initialized
"failed" -> Failed <$> v .: "error"
"shared" -> SharedReference <$> v .: "reference"
_ -> fail "Unknown status"
-------------------------------------------------------------------------------
-- Diagnostics Collector (Interceptor-based)
-------------------------------------------------------------------------------
-- | Mutable state for collecting diagnostics during layer initialization
data DiagnosticsCollectorState = DiagnosticsCollectorState
{ collectorNodeStack :: ![LayerNode]
-- ^ Stack of nodes being built (top = current)
, collectorNextId :: !Int
-- ^ Next available node ID
, collectorStartTime :: !UTCTime
-- ^ When collection started
, collectorServiceMap :: !(HashMap TypeRep Text)
-- ^ Map of service types to their node IDs (for tracking sharing)
, collectorTotalResources :: !Int
-- ^ Total number of resources allocated
, collectorSharedResources :: !Int
-- ^ Number of shared/reused resources
}
-- | Opaque handle to a diagnostics collector
newtype DiagnosticsCollector = DiagnosticsCollector (MVar DiagnosticsCollectorState)
-- | Create a new diagnostics collector
newDiagnosticsCollector :: MonadIO m => m DiagnosticsCollector
newDiagnosticsCollector = liftIO $ do
start <- getCurrentTime
ref <- newMVar DiagnosticsCollectorState
{ collectorNodeStack = [rootNode]
, collectorNextId = 1
, collectorStartTime = start
, collectorServiceMap = HashMap.empty
, collectorTotalResources = 0
, collectorSharedResources = 0
}
pure $ DiagnosticsCollector ref
where
rootNode = LayerNode
{ nodeId = "root"
, nodeName = "Root"
, nodeType = ComposedNode
, resourceType = Nothing
, status = Initializing
, duration = Nothing
, children = []
, metadata = HashMap.empty
}
-- | Finalize diagnostics and get the complete tree
finalizeDiagnostics :: MonadIO m => DiagnosticsCollector -> m LayerDiagnostics
finalizeDiagnostics (DiagnosticsCollector ref) = liftIO $ do
state <- readMVar ref
endTime <- getCurrentTime
let totalDur = realToFrac (diffUTCTime endTime (collectorStartTime state))
rootNode = case collectorNodeStack state of
[] -> error "Diagnostics collector has empty stack"
(node:_) -> node { status = Initialized, duration = Just totalDur }
pure $ LayerDiagnostics
{ rootNode = rootNode
, totalDuration = totalDur
, totalResources = collectorTotalResources state
, sharedResources = collectorSharedResources state
}
-- | Create a LayerInterceptor that collects diagnostics
createDiagnosticsInterceptor :: MonadIO m => DiagnosticsCollector -> LayerInterceptor m
createDiagnosticsInterceptor collector = LayerInterceptor
{ onResourceAcquire = \ctx -> liftIO $
diagStartNode collector ctx ResourceNode
, onResourceAcquireComplete = \_ dur -> liftIO $
diagEndNode collector dur Initialized
, onResourceRelease = \_ -> pure ()
, onEffectRun = \ctx -> liftIO $
diagStartNode collector ctx EffectNode
, onEffectComplete = \_ dur -> liftIO $
diagEndNode collector dur Initialized
, onServiceCreate = \ctx -> liftIO $ do
diagStartNode collector ctx ServiceNode
diagRegisterService collector ctx
, onServiceReuse = \name tr -> liftIO $
diagAddSharedRef collector name tr
, onCompositionStart = \typ -> liftIO $ do
let nt = case typ of
Sequential -> SequentialNode
Parallel -> ParallelNode
ctx = OperationContext (T.pack (show typ)) Nothing []
diagStartNode collector ctx nt
, onCompositionEnd = \_ dur -> liftIO $
diagEndNode collector dur Initialized
}
diagStartNode :: DiagnosticsCollector -> OperationContext -> LayerNodeType -> IO ()
diagStartNode (DiagnosticsCollector ref) ctx nt =
modifyMVar_ ref $ \state -> do
let newNode = LayerNode
{ nodeId = T.pack ("node-" <> show (collectorNextId state))
, nodeName = operationName ctx
, nodeType = nt
, resourceType = operationType ctx
, status = Initializing
, duration = Nothing
, children = []
, metadata = HashMap.fromList (operationMetadata ctx)
}
pure state
{ collectorNodeStack = newNode : collectorNodeStack state
, collectorNextId = collectorNextId state + 1
}
diagEndNode :: DiagnosticsCollector -> NominalDiffTime -> ResourceStatus -> IO ()
diagEndNode (DiagnosticsCollector ref) dur st =
modifyMVar_ ref $ \state ->
pure $ case collectorNodeStack state of
[] -> state
[root] ->
state { collectorNodeStack =
[root { status = st, duration = Just (realToFrac dur) }]
}
(current:parent:rest) ->
let completed = current { status = st, duration = Just (realToFrac dur) }
in state { collectorNodeStack =
parent { children = children parent ++ [completed] } : rest
}
diagRegisterService :: DiagnosticsCollector -> OperationContext -> IO ()
diagRegisterService (DiagnosticsCollector ref) ctx =
modifyMVar_ ref $ \s -> pure $ case operationType ctx of
Just tr -> s
{ collectorServiceMap = HashMap.insert tr (operationName ctx) (collectorServiceMap s)
, collectorTotalResources = collectorTotalResources s + 1
}
Nothing -> s
{ collectorTotalResources = collectorTotalResources s + 1
}
diagAddSharedRef :: DiagnosticsCollector -> Text -> TypeRep -> IO ()
diagAddSharedRef (DiagnosticsCollector ref) name tr =
modifyMVar_ ref $ \state ->
case HashMap.lookup tr (collectorServiceMap state) of
Just originalNodeId -> do
let sn = LayerNode
{ nodeId = T.pack ("shared-" <> show (collectorNextId state))
, nodeName = name
, nodeType = ServiceNode
, resourceType = Just tr
, status = SharedReference originalNodeId
, duration = Nothing
, children = []
, metadata = HashMap.empty
}
pure $ case collectorNodeStack state of
[] -> state
(top:rest) ->
state
{ collectorNodeStack = top { children = children top ++ [sn] } : rest
, collectorNextId = collectorNextId state + 1
, collectorSharedResources = collectorSharedResources state + 1
}
Nothing -> pure state
-------------------------------------------------------------------------------
-- Running with Diagnostics
-------------------------------------------------------------------------------
-- | Build a layer with diagnostics tracking and return only the diagnostics.
--
-- The environment is discarded and resources are released immediately.
-- Useful for inspecting layer structure without keeping resources alive.
-- Use 'withLayerDiagnostics' when you need both the environment and diagnostics.
buildLayerDiagnostics ::
MonadUnliftIO m =>
-- | Layer to build
Layer m deps env ->
-- | Dependencies
deps ->
m LayerDiagnostics
buildLayerDiagnostics layer deps = do
collector <- newDiagnosticsCollector
let interceptor = createDiagnosticsInterceptor collector
_ <- runLayerWithInterceptor interceptor deps layer
finalizeDiagnostics collector
-- | Run a layer with diagnostics enabled.
-- Returns both the environment and the diagnostics information.
--
-- This function runs the layer with diagnostics collection enabled, then provides
-- both the initialized environment and collected diagnostics to your continuation.
--
-- Example:
-- @
-- withLayerDiagnostics myLayer config $ \(env, diags) -> do
-- putStrLn $ renderLayerTree diags
-- -- Use the environment...
-- @
withLayerDiagnostics ::
MonadUnliftIO m =>
-- | Layer to run
Layer m deps env ->
-- | Dependencies
deps ->
-- | Action with environment and diagnostics
((env, LayerDiagnostics) -> m r) ->
m r
withLayerDiagnostics layer deps action = do
collector <- newDiagnosticsCollector
let interceptor = createDiagnosticsInterceptor collector
withLayerAndInterceptor interceptor deps layer $ \env -> do
diags <- finalizeDiagnostics collector
action (env, diags)
-------------------------------------------------------------------------------
-- Rendering to Terminal
-------------------------------------------------------------------------------
-- | Render the layer tree as ASCII art (compact version)
--
-- Example output:
-- @
-- Root
-- ├── ConfigLayer (0.05s)
-- └── ParallelComposition (0.3s)
-- ├── DatabaseLayer (0.2s)
-- │ └── ConnectionPool [SHARED: svc-001]
-- └── WebServerLayer (0.1s)
-- └── MetricsCollector [SHARED: svc-001]
-- @
renderLayerTree :: LayerDiagnostics -> String
renderLayerTree diags =
unlines $
[ "Layer Initialization Tree"
, "═════════════════════════"
, ""
, "⧗ Duration: " ++ show (totalDuration diags) ++ "s"
, "◆ Resources: " ++ show (totalResources diags)
, "↻ Shared: " ++ show (sharedResources diags)
, ""
] ++ renderNode "" True (rootNode diags)
-- | Detailed rendering with metadata
renderLayerTreeDetailed :: LayerDiagnostics -> String
renderLayerTreeDetailed diags =
unlines $
[ "Layer Initialization Tree (Detailed)"
, "═════════════════════════════════════"
, ""
, "⧗ Duration: " ++ show (totalDuration diags) ++ "s"
, "◆ Resources: " ++ show (totalResources diags)
, "↻ Shared: " ++ show (sharedResources diags)
, ""
] ++ renderNodeDetailed "" True (rootNode diags)
-- | Render a single node in the tree
renderNode :: String -> Bool -> LayerNode -> [String]
renderNode prefix isLast node =
let connector = if isLast then "└── " else "├── "
extension = if isLast then " " else "│ "
typeSymbol = nodeTypeSymbol (nodeType node)
nodeInfo = typeSymbol ++ " " ++ T.unpack (nodeName node) ++ formatDuration (duration node) ++ formatStatus (status node)
header = prefix ++ connector ++ nodeInfo
childPrefix = prefix ++ extension
childCount = length (children node)
childLines = concat $ zipWith (\i child -> renderNode childPrefix (i == childCount - 1) child) [1..] (children node)
in header : childLines
-- | Render a node with detailed metadata
renderNodeDetailed :: String -> Bool -> LayerNode -> [String]
renderNodeDetailed prefix isLast node =
let connector = if isLast then "└── " else "├── "
extension = if isLast then " " else "│ "
typeSymbol = nodeTypeSymbol (nodeType node)
nodeInfo = typeSymbol ++ " " ++ T.unpack (nodeName node) ++ " [" ++ show (nodeType node) ++ "]"
header = prefix ++ connector ++ nodeInfo
childPrefix = prefix ++ extension
-- Add metadata lines
metaLines = if HashMap.null (metadata node)
then []
else map (\(k, v) -> childPrefix ++ " ▸ " ++ T.unpack k ++ ": " ++ T.unpack v) (HashMap.toList $ metadata node)
-- Add type info if available
typeLines = case resourceType node of
Nothing -> []
Just tr -> [childPrefix ++ " ▸ Type: " ++ show tr]
-- Add duration
durationLines = case duration node of
Nothing -> []
Just d -> [childPrefix ++ " ⧗ " ++ show d ++ "s"]
-- Add status
statusLines = [childPrefix ++ " " ++ formatStatusDetailed (status node)]
detailLines = typeLines ++ durationLines ++ statusLines ++ metaLines
childCount = length (children node)
childLines = concat $ zipWith (\i child -> renderNodeDetailed childPrefix (i == childCount - 1) child) [1..] (children node)
in (header : detailLines) ++ childLines
-- | Symbol for each node type
nodeTypeSymbol :: LayerNodeType -> String
nodeTypeSymbol ResourceNode = "◆"
nodeTypeSymbol EffectNode = "⚡"
nodeTypeSymbol ServiceNode = "◉"
nodeTypeSymbol ComposedNode = "⊕"
nodeTypeSymbol ParallelNode = "⋈"
nodeTypeSymbol SequentialNode = "⇒"
formatDuration :: Maybe Double -> String
formatDuration Nothing = ""
formatDuration (Just d) = " ⧗" ++ show d ++ "s"
formatStatus :: ResourceStatus -> String
formatStatus Initializing = " ⟳"
formatStatus Initialized = " ✓"
formatStatus (Failed err) = " ✗ [" ++ err ++ "]"
formatStatus (SharedReference refId) = " ↻ " ++ T.unpack refId
formatStatusDetailed :: ResourceStatus -> String
formatStatusDetailed Initializing = "⟳ Initializing"
formatStatusDetailed Initialized = "✓ Initialized"
formatStatusDetailed (Failed err) = "✗ Failed - " ++ err
formatStatusDetailed (SharedReference refId) = "↻ Shared reference to " ++ T.unpack refId
-- | Get a snapshot of current diagnostics without finalizing
--
-- This is useful for live rendering while initialization is still in progress.
-- Unlike 'finalizeDiagnostics', this doesn't mark the collector as finished.
snapshotDiagnostics :: MonadIO m => DiagnosticsCollector -> m LayerDiagnostics
snapshotDiagnostics (DiagnosticsCollector ref) = liftIO $ do
state <- readMVar ref
endTime <- getCurrentTime
let totalDur = realToFrac (diffUTCTime endTime (collectorStartTime state))
root = case collectorNodeStack state of
[] -> LayerNode
{ nodeId = "empty"
, nodeName = "Empty"
, nodeType = ComposedNode
, resourceType = Nothing
, status = Initialized
, duration = Nothing
, children = []
, metadata = HashMap.empty
}
(n:_) -> n
pure LayerDiagnostics
{ rootNode = root
, totalDuration = totalDur
, totalResources = collectorTotalResources state
, sharedResources = collectorSharedResources state
}
-------------------------------------------------------------------------------
-- JSON Export
-------------------------------------------------------------------------------
-- | Convert diagnostics to a JSON Value
diagnosticsToJSON :: LayerDiagnostics -> Value
diagnosticsToJSON = toJSON