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]])
2 Likes

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.

@Voyd I’d use this myself if you choose to publish the library!

It looks like log-effectful is the only effectful logging library available at the moment.

Looks like the co-log wrapper is cursed, I’ve seen 3 people mention that they were working on it, but there is no published library :stuck_out_tongue:

AFAIK @eldritch-cookie was working on it recently.

1 Like

maybe they met the same problem i have currently,
because co-log is really abstract and complete it is really hard to find things you need to do beyond effect + interpreter,
i personally was brainstorming ideas for implementing structured logging like co-log in tagless final tries to do.
@srid what exactly do you need? i guess as it currently is my “library” is about as complete as co-log-polysemy and i don’t have anything preventing me from releasing it besides minor stuff like
1 not wanting to release something so small again
2 having only 66% test coverage on less than 100 lines is kinda embarrassing

Just basic console application logging to stdout or stderr with colours would be pretty handy. I have no plans for structured logging, streaming or complex processing.

Any chance you could release something basic? I’d be happy to give it a try and give feedback.

i have a version up co-log-effectful: effectful log effect using co-log-core hackage apparently hates me so its says that the install failed but the documentation is built and i personally tested with ghc 9.10.1 , 9.6.6 and 9.6.3 after the first build failure. if you need anything more or find a bug feel free to open an issue.

1 Like

Thank you! I am able to use it with,

-- | Like `runLogAction` but works with `Message` and writes to `Stdout` (the common use-case)
runLogActionStdout :: Eff '[Log Message, IOE] a -> Eff '[IOE] a
runLogActionStdout =
  runLogAction logAction
  where
    logAction = LogAction $ \m ->
      putTextLn $ fmtMessage m

{- | Log a message with the given severity.

>>> import Vira.Logging (log, Severity(Info))
>>> log Info "Hello, world!"
-}
log :: forall es. (HasCallStack, Log Message :> es) => Severity -> Text -> Eff es ()
log msgSeverity msgText =
  withFrozenCallStack $ logMsg $ Msg {msgStack = callStack, ..}

1 Like