Feedback for potential first library: co-log-effectful

Hey folks,

I’d like to kindly ask for comments on this module I wrote. I have been experimenting on how to bring effectful and co-log together in a way that still allows using the combinators that distinguish co-log, and I’ve finally got things working.

I don’t have a whole lot of experience with Haskell though (this is all still for my toy projects), so I’m wondering

  • is this a “good” interface? It seems nice to me but I haven’t worked with other logging libraries
  • is this a significant enough addition to warrant a package? It’s not a whole lot of code, but it took me a hot minute to get there.
  • Are there strictness issues with the State and Writer interpreters?

Next tasks:

  • Include comfy functions for logging with Severity and RichMessages
  • More specific tests
  • See if we can reduce the need for type applications

I also posted the code as a gist in case you prefers that for reading

Here’s the rendered haddock. Ignore the other modules, as I wrote it’s a chaotic toy project for the most part.

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeFamilies #-}

{- |
Description: effectful interface to co-log
License: GPL-3

Provides an 'Effectful' interface to 'Colog' using dynamic dispatching.

It allows adding logging without much ceremony as well as running 'LogAction's
defined through others that are in the environment at that time.
-}
module Colog.Effectful (
    -- * Effect
    Log,

    -- * Actions
    log,
    getLogAction,
    LogActionEff,

    -- * Running
    runLog,
    runLogF,
    runLogM,

    -- * Pure interpreters
    runLogWriterD,
    runLogWriterSL,
    runLogWriterSS,
    runLogStateD,
    runLogStateSL,
    runLogStateSS,
)
where

import Colog (LogAction (LogAction), choose, divide, logTextStdout, (<&))
import Control.Concurrent.Async (cancel)
import Effectful (
    Dispatch (Dynamic),
    DispatchOf,
    Eff,
    Effect,
    runEff,
    type (:>),
 )
import Effectful.Dispatch.Dynamic (
    EffectHandler,
    interpret,
    localSeqLift,
    send,
 )
import Effectful.Dispatch.Static (SideEffects (WithSideEffects), StaticRep)
import Effectful.Reader.Static qualified as RS
import Effectful.State.Dynamic qualified as SD
import Effectful.State.Static.Local qualified as SSL
import Effectful.State.Static.Shared qualified as SSS
import Effectful.Writer.Dynamic qualified as WD
import Effectful.Writer.Static.Local qualified as WSL
import Effectful.Writer.Static.Shared qualified as WSS


---------
-- Effect

{- | Effect for logging messages of type @msg@.

It means that there is a suitable 'LogAction _ msg' available in the environment.
-}
type Log :: Type -> Effect
data Log msg :: Effect where
    Log :: msg -> Log msg effM ()
    GetLogAction :: Log msg (Eff es) (LogActionEff es msg)


type instance DispatchOf (Log _) = 'Dynamic


----------
-- Actions

-- | An adaptation of 'LogAction' for @effectful@
type LogActionEff :: [Effect] -> Type -> Type
type LogActionEff es msg = LogAction (Eff es) msg


{- | Log a message of type 'msg' using the handler in the environment.

This will log "hi" to standard out

@
runEff
. runLog logTextStdout
$ log @Text "hi"
@

Note that for plain text types, type applications are necessary because there could be
different handlers for e.g. String and Text
-}
log ::
    forall msg es.
    (Log msg :> es) =>
    msg ->
    Eff es ()
log = send . Log


{- | Retrieve the logger for 'msg' from the environment.

This is mostly useful to define dependent 'LogActionEff's, for example

>>> mkLogWeirdEither ::
>>>     forall es.
>>>     (Log Text :> es, Log Char :> es) =>
>>>     Eff es (LogActionEff es (Bool, Bool, [Int]))
>>> mkLogWeirdEither = do
>>>     logText <- getLogAction @Text
>>>     let boolToChar b = if b then 'y' else 'n'
>>>     logBool <- (boolToChar >$<) <$> getLogAction @Char
>>>     let toEither (True, bool, _) = Left bool
>>>         toEither (False, _, list) = Right list
>>>     pure $ choose toEither logBool (show >$< logText)
>>>
>>> doIt :: ((), Seq Text)
>>> doIt =
>>>     runPureEff
>>>     . WSL.runWriter @(Seq Text)
>>>     . runLogWriterSL @Seq @Text
>>>     . runLogM @Char ((show >$<) <$> getLogAction @Text)
>>>     . runLogM mkLogWeirdEither
>>>     $ do
>>>         log (True, False, [3, 4 :: Int])
>>>         log @Text "foo"
>>>         log '?'
>>>         log (False, True, [8, 9 :: Int])
>>>
>>> doIt
((),fromList ["'n'","foo","'?'","[8,9]"])
-}
getLogAction ::
    forall msg es.
    (Log msg :> es) =>
    Eff es (LogActionEff es msg)
getLogAction =
    send GetLogAction


-- | Run the effectful computation using the given 'LogActionEff' to log 'msg's
runLog ::
    forall msg es a.
    LogActionEff es msg ->
    Eff (Log msg : es) a ->
    Eff es a
runLog = runLogM . pure


{- | Run the effectful computation using the given action as a handler to log 'msg's

@runLogF = runLog . LogAction@
-}
runLogF ::
    forall msg es a.
    (msg -> Eff es ()) ->
    Eff (Log msg : es) a ->
    Eff es a
runLogF = runLog . LogAction


{- | Run the effectful computation using the result of the given action as a handler to log 'msg's.

This allows additional effects to be used when /constructing/ the 'LogActionEff'.
(Note that effects are always possible when processing 'msg's.)

This is necessary when a handler is defined in terms of other handlers. In that case, you can
retrieve and combine the base handlers.

See 'getLogAction' for an example.
-}
runLogM ::
    forall msg es a.
    Eff es (LogActionEff es msg) ->
    Eff (Log msg : es) a ->
    Eff es a
runLogM mkLogAction task = do
    LogAction doLog <- mkLogAction
    let handler :: EffectHandler (Log msg) es
        handler localEnv = \case
            Log msg ->
                doLog msg
            GetLogAction -> do
                localSeqLift localEnv \lift ->
                    pure $ LogAction (lift . doLog)
    interpret handler task


---
-- Specific interpreters

{- | Log using 'Effectful.Writer.Dynamic.Writer'.

Because of performance, you probably don't want to use this with regular lists ('[]').
'Data.Sequence.Seq' should be fine.
-}
runLogWriterD ::
    forall s msg es a.
    (Applicative s, WD.Writer (s msg) :> es) =>
    Eff (Log msg : es) a ->
    Eff es a
runLogWriterD =
    runLogF $ WD.tell . pure @s


{- | Log using 'Effectful.Writer.Static.Local.Writer'.

Because of performance, you probably don't want to use this with regular lists ('[]').
'Data.Sequence.Seq' should be fine.
-}
runLogWriterSL ::
    forall s msg es a.
    (Monoid (s msg), Applicative s, WSL.Writer (s msg) :> es) =>
    Eff (Log msg : es) a ->
    Eff es a
runLogWriterSL =
    runLogF $ WSL.tell . pure @s


{- | Log using 'Effectful.Writer.Static.Shared.Writer'.

Because of performance, you probably don't want to use this with regular lists ('[]').
'Data.Sequence.Seq' should be fine.
-}
runLogWriterSS ::
    forall s msg es a.
    (Monoid (s msg), Applicative s, WSS.Writer (s msg) :> es) =>
    Eff (Log msg : es) a ->
    Eff es a
runLogWriterSS =
    runLogF $ WSS.tell . pure @s


-- | Log using 'Effectful.State.Dynamic.State' over a Semigroup.
runLogStateD ::
    forall s msg es a.
    (Applicative s, Semigroup (s msg), SD.State (s msg) :> es) =>
    Eff (Log msg : es) a ->
    Eff es a
runLogStateD =
    runLogF $ \msg -> SD.modify (<> pure @s msg)


-- | Log using 'Effectful.State.Static.Local.State' over a Semigroup.
runLogStateSL ::
    forall s msg es a.
    (Monoid (s msg), Applicative s, SSL.State (s msg) :> es) =>
    Eff (Log msg : es) a ->
    Eff es a
runLogStateSL =
    runLogF $ \msg -> SSL.modify (<> pure @s msg)


-- | Log using 'Effectful.State.Static.Shared.State' over a Semigroup.
runLogStateSS ::
    forall s msg es a.
    (Monoid (s msg), Applicative s, SSS.State (s msg) :> es) =>
    Eff (Log msg : es) a ->
    Eff es a
runLogStateSS =
    runLogF $ \msg -> SSS.modify (<> pure @s msg)

module Fairtalk.CologTest where

import Colog (LogAction (LogAction), choose, divide, (<&))
import Effectful (Eff, runEff, runPureEff, (:>))
import qualified Effectful.Writer.Static.Local as WSL
import Fairtalk.Colog
import Test.Hspec


logCharAsText ::
    (Log Text :> es) =>
    LogActionEff es Char
logCharAsText = LogAction \msg -> do
    logText <- getLogAction @Text
    (show >$< logText) <& msg


logTuple ::
    (Log Text :> es) =>
    (Log Char :> es) =>
    Eff es (LogActionEff es (Char, Text))
logTuple =
    divide id <$> getLogAction <*> getLogAction


coolStuff :: ((), Seq Text)
coolStuff =
    runPureEff
        . WSL.runWriter @(Seq Text)
        . runLogWriterSL @Seq @Text
        . runLog logCharAsText
        . runLogM logTuple
        $ do
            log @Text "hello"
            log ('z', "Foo" :: Text)
            runLogM @[Int]
                ( do
                    textLogger <- getLogAction @Text
                    pure (show >$< textLogger)
                )
                do
                    log [1, 2, 3 :: Int]
            runLogM @[Int] ((show >$<) <$> getLogAction @Text) $
                log [6, 7, 8 :: Int]
            log 'x'


mkLogWeirdEither ::
    forall es.
    (Log Text :> es, Log Char :> es) =>
    Eff es (LogActionEff es (Bool, Bool, [Int]))
mkLogWeirdEither = do
    logText <- getLogAction @Text
    let boolToChar b = if b then 'y' else 'n'
    logBool <- (boolToChar >$<) <$> getLogAction @Char
    let toEither (True, bool, _) = Left bool
        toEither (False, _, list) = Right list
    pure $ choose toEither logBool (show >$< logText)


doIt :: ((), Seq Text)
doIt =
    runPureEff
        . WSL.runWriter @(Seq Text)
        . runLogWriterSL @Seq @Text
        . runLogM @Char ((show >$<) <$> getLogAction @Text)
        . runLogM mkLogWeirdEither
        $ do
            log (True, False, [3 :: Int, 4])
            log @Text "foo"
            log '?'
            log (False, True, [8 :: Int, 9])


spec_runLogM :: Spec
spec_runLogM = do
    it "allows using more effects (coolStuff)" do
        coolStuff `shouldBe` ((), fromList ["hello", show 'z', "Foo", "[1,2,3]", "[6,7,8]", show 'x'])
    it "does this other thing also (doIt)" do
        doIt `shouldBe` ((), fromList [show 'n', "foo", show '?', show [8, 9 :: Int]])

Hi @Voyd,

Nice to see more effectful libraries popping up! One immediate thought for me is in your definition of Log. You have both Log and GetLogAction, but it looks like Log is really redundant. I say this, because we can write:

log :: Log msg :> es => msg -> Eff es ()
log msg = do
  logAction <- getLogAction
  logAction <& msg

and nothing is lost (though I haven’t type checked this).

Is there any reason you want both Log and GetLogAction in your effect definition? Usually one wants axioms in the effect definition

1 Like

Good point, thank you.
I added GetLogAction after a trying way too hard to use a Reader effect for this, so you might be right. I’ll check it out.