How I write PureScript web-apps: Part-II

On Tue, 11 Feb 2025, by @lucasdicioccio, 1554 words, 6 code snippets, 4 links, 0images.

This article dives straight into discussing cross-cutting concerns. If you need additional context or wish to grasp the core state management, rendering, and event-handling layout I recommend for a Halogen application, please read the previous article.

logging and analytics with contravariant Tracers

Modern applications necessitate logging analytics about events occurring on a page. Commercial tools often instrument pages in their own manner to collect data on what happens on a web page; however, this can lead to mismatch with how you write your own application, for instance, you may forget to annotate some handler.

An advantage of pure-functional programming is that we have excellent control over where side-effects and user-actions occur. As a result, it is almost trivial to collect every event that has occurred: just decorate the handle action with some wrapper, as well as some mechanism to decorate effects with a summary of what happened.

A slight difficulty arises when we want to capture events occurring in sub-components, which are thus hidden from the Page-component. We can easily solve the problem with a solution named contravariant-logging. Contravariant-logging is a style where we pass an increasingly-decorated callback down the call-stacks. The decoration function uses a comap (or “contra-map”) function that enables capturing the extra scope-information at every layer. This style gets you extremely-close to what OpenTelemetry defines as spans, but without pushing a third-party library or system onto you.

introducing and composing Tracers

Let’s define a simple Tracer (or logger), which is parametrized by what we are logging. Given how short the definition is, let’s also define the comap function which allows to decorate logged events. Technically, we can do more than decorate the function, such as flattening nesting levels and translating ontologies, but the common use case for us is to add a wrapping layer with extra information.

type Tracer obj = obj -> Effect Unit

comap :: forall obj1, obj2. Tracer obj1 -> (obj2 -> obj1) -> Tracer obj2
comap run adapt = \obj2 -> run (adapt obj1)

In TypeScript, the equivalent would roughly be the following.

type Tracer<Obj> = (obj : Obj) => void;


function comap<Obj1,Obj2>(handle : Tracer<Obj1>, adaptInput : ((o2 : Obj2) => Obj1)) : Tracer<Obj2> {
    return ((obj2Item) => handle(adaptInput(obj2Item)));
  };

The advantage of the approach is that every component in our application can define a type for its own Trace.

data Trace
  = TraceAction (Seqnum "action") Action

-- with Action being the page handler

data Action
  = InitializePage
  | ...
  | PublishArticle (Seqnum "article")
  | DestroyArticle (Seqnum "article")
  | ...

In the above excerpt, we repurpose the Action definition from the article-part-one. We define a Trace as an Action that occurred, distinguished by a sequential number (Seqnum) assigned to each event in our application. This Seqnum facilitates debugging by providing a straightforward way to identify specific actions within our codebase.

consuming Tracers is easy and composable

At its core, a Tracer is just a function that has an Effect and returns no information. That is, the application asking for a Tracer can do little more than feed it some input and cannot ask anything in return. It’s akin to saying “for this application, I want to throw objects into a black hole.” An advantage of this black-hole aspect is that it is easy for library users to provide such functions. Unlike some dependency-injection frameworks requiring abstract configuration and introspection/compile-time magic, you can almost always add a no-op handler that does nothing: traceSilent _ = pure unit or, in TypeScript parlance, const traceSilent = () => (). A more useful Tracer would be something like console.log, but you can also perform HTTP requests or start virtually any process; the application has no expectation of your handler.

Another advantage of the Tracer definition is that you can combine Tracer with so-called combinator functions. For instance, traceBoth :: forall a. Tracer a -> Tracer a -> Tracer a allows you to define two handlers separately (e.g., a “console-tracer” and a “report to the centralized-error-system-tracer”) and merge them into a single Tracer. You can also combine sub-Tracers specialized in subsets of events, traceSplit :: forall a b c -> (a -> Tuple b c) -> Tracer b -> Tracer c -> Tracer a. This pattern helps you split events so that anything containing user-identifiers can be censored-out rather than sent to an analytics collector. Overall, composing Tracer is a powerful mechanism that feels declarative and avoids the limitations and annoyance of learning an “XML-configuration” configuration.

contra-mapping Tracers to reflect the module and effects structure

Let’s elaborate more on the the Trace object, say we have Graphs components from my Halogen-Echarts library, charts want to be in a Halogen Component so that they can manage and hide a reference to the DOM object that Echarts handles. In our Page component, we address Graphs by naming them with an individual Graph with a Seqnum "graph". Then, if our Graph sub-component defines its own set of Traces, it will require a Tracer Graph.Trace as a parameter.

Thanks to comap, we can nest Traces from sub-components, say we have Graphs components, which are stored in the Entity store with a Seqnum "graph".

data Trace
  = TraceAction (Seqnum "action") Action
  | TraceGraph (Seqnum "graph") Graph.Trace

render_graph :: Tracer Trace -> State -> GraphEntity -> HTMLRenderingFunction
render_graph mainTracer state graph = 
  let
    modifiedTracer :: Tracer Graph.Trace
    modifiedTracer = comap mainTracer (TraceGraph graph.seqnum)
  in
  HH.div_
  [ H.slot_ (Graph.component modifiedTracer ...)
  ]

The above code won’t compile as it is incomplete, but the key to understand the power and simplicity of contravariant tracing is that we can provide a Tracer Graph.Trace by merely embedding the sub-traces into our main Tracer. For instance, we could have elaborated with more information if needed (e.g., if we want to capture the Open/Closed state of a drawer containing a graph because we had curious bugs of drawer re-opening), we would do add a DrawerOpenState to the TraceGraph constructor definition and the type-checker in PureScript would guide us into setting the right value at the right place in the code.

nested-Actions lead to tree-shaped Traces

Another important decoration we can do has to do with Actions that have other Actions as consequences. This problem occurs when we have multiple mechanisms to affect an application in similar but non-overlapping ways. For instance, a user is prompted with a pop-up and they can choose to ignore (simply closing the pop-up) or perform some action that will re-trigger an API call (this type of thing often happens in dashboard applications like PostgREST-Table). You probably want to also close the pop-up upon performing an action. As a result, it becomes natural to have some way to Batch events or to somehow, call the core handler function from within the handler function itself. We can capture this nesting with (you guessed it) some comparator. It turns out that our Trace object can be a tree as well. This result is somewhat expected if you follow the over-arching theme of the blog-post series: components, events, rendering functions and now logging events should all follow a look-alike structure.

Transforming our Trace into a Tree-shaped trace is not as challenging as it sounds in a pure-functional-programming setup: make the definition of Trace recursive, and let the compiler guide you.

data Trace
  = TraceAction (Seqnum "action") Action
  | WithinAction (Seqnum "graph") Trace

page
  :: forall query output m. MonadAff m
  => { tracer :: Tracer Trace }
  -> H.Component query PageInput output m
page { tracer } =
  H.mkComponent
    { initialState: \i -> state0 i.storedState
    , render
    , eval: H.mkEval $ H.defaultEval
        { handleAction = \act -> do
            seqnum <- Seqnum.allocate
            H.liftEffect (trace tracer $ TraceAction seqnum act)
            Handlers.handleAction (comap (WithinAction seqnum) tracer) act
        , initialize = Just Initialize
        }
    }

This excerpt shows how my Page component, asks for a Tracer as extra input.

Then the handleAction is implemented in terms of:

  • picking a Seqnum to identify the Action as being one thing
  • logging the current Action with the Seqnum
  • calling a concrete handler holding the business logic (Handlers.handleAction) with our main application tracer, but decorated with the information about the fact that any further actions will occur within the span of our main action

With this structure, you should be able to capture all Halogen actions going through the main handler. And if you’ve followed the previous article, it means you collect properly-nested and annotated events. Further, you are free to record all relevant state snapshots with a TraceState State if you desire so. Consequently, if you wish to track down a significant bug, you need not litter your main code with non-business code.

Since tracing is an effect, you can even collect traces from event-handlers like ApiCalls.

besides Halogen actions, network calls

With this infrastructure in place, we now have a way to handle so many things happening in our Page component from the Main JavaScript application. Contravariant Tracing, a simple mechanism, provides us with a way to know everything that occurs in our components, and in a composable and type-checked way. By the virtue of their type, you know your application will not expect anything (apart from not-crashing and not pausing forever) from your tracer implementation.

Let’s say you want to also log actions like API-calls, you have a pattern: each API module will define its own Trace object, and you could have some structure as follows:


-- module Page
data Trace
   = TraceAction <stuff>
   | TraceAuthApi <stuff> (AuthApi.Trace)
   | TraceInferenceApi <stuff> (InferenceApi.Trace)

-- module Api.Auth
data Trace
   = LoginAttempt UserId AjaxApi.Trace
   | LoginFailed UserId LoginAttemptError
   | LoginSuccess UserId

data LoginAttemptError
   = NetworkError AjaxApi.Error
   | ChallengeFailed FailureReason

-- module Api.Inference
data Trace
   = EmbedRequest AjaxApi.Trace
   | EmbedResponse ...
   | GenerateRequest AjaxApi.Trace
   | ...

In short: we trace every API call made in any API-related module and return this information well-decorated to the top of the application. If suddenly the AjaxApi.Trace changes (for instance to log some new information about an HTTP-header), what happens is that the layers who decided to just embedd the underlying AjaxApi.Trace need not change anything. Whereas, you top-level handlers (e.g., to trace in the console) will likely need some updating if they where inspecting the content of the AjaxApi.Trace (e.g., to censor a risky header).

Conclusion

The contravariant tracing pattern is a cornerstone of how I architect PureScript-Halogen web-applications. The pattern induces minimal cognitive overhead: while adding components or API-calls we just need to define a Trace (which can be as simple as type equivalence between Action and Trace) and we then follow the compiler errors. In general, from the duality-principle, if we add a new alternative trace object we’ll need to provide an extra handler-branch where the application-level Tracer is defined; whereas if accumulate new information we’ll get a change to leverage that extra bit to branch/filter in our Tracer. It turns out that is way of coding is exactly what we need to stay in development-flow state. You will not ask yourself too many questions in applying the pattern, you will not miss an event in your analytics or logging traces, and on top of this, you will not be concerned by type errors at runtime.

I’ve yet more things to say about the way I recommend architecting web-applications. Hence, stay tuned for a third article. The third article will discuss how to initialize an application and how to handle saving and resuming the state in a non-invasive manner. Stay tuned.