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
= \obj2 -> run (adapt obj1) comap run adapt
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: \i -> state0 i.storedState
{ initialState
, render: H.mkEval $ H.defaultEval
, eval= \act -> do
{ handleAction <- Seqnum.allocate
seqnum $ TraceAction seqnum act)
H.liftEffect (trace tracer WithinAction seqnum) tracer) act
Handlers.handleAction (comap (= Just Initialize
, 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.