Like many applications, the Slack desktop app logs how users interact with it. For example, it may log when a user views a screen or clicks on a button. Product Managers and Data Scientists analyze the logs, hoping to discover actionable insights to drive product refinements.
Slack’s desktop application is written in React. We built a React analytics logging library to: 1) increase developer velocity by making it easier to write logging code, 2) reduce log data errors, and 3) create a viewer to get a real-time look at logging. In this first (of two parts) article, we examine how we reached these goals, reviewing key pieces of the library: data sharing, logging impressions, and other events.
We’ve broken this post down into three sections:
- Sharing Data Efficiently: how to share log data between components without manual data copying and code duplication — thereby increasing velocity and reducing data errors
- Smarter Impressions: how we made it easy to log impressions while improving impression accuracy
- Easy Event Logging: how we simplified logging events, like clicks, while bolstering data consistency
Let’s dive in!
Sharing Data Efficiently
Traditionally, analytics logging in React has been done by importing a logging function into a component file and calling it with specified data inside a lifecycle or event handler method. While this approach works, there is room for improvement.
To start, the approach leads to duplicate code. Say we want to log an impression when a user views a home page. We’ll import our logging function into our home page component file. Then, in our componentDidMount or useEffect-equivalent, we’ll call:
sendLog({ page: 'home', action: 'impression' });
So far, so good. Next, say we want to log an impression of a section on the home page. We’ll find the section component file, import the sendLog
function, and in componentDidMount
call:
sendLog({ page: 'home', section: 'welcome section', action: 'impression' });
Alright, a bit of copying. Then, say we also want to log a banner that appears in the welcome section. Again, import sendLog
into the banner component, and in its componentDidMount
call:
sendLog({ page: 'home', section: 'welcome section', component: 'banner', elementName: 'promo banner', action: 'impression' });
I’m sure you see the issue. We have to import the sendLog
function into each file, repeatedly write code in componentDidMount
, and add the same key-values as we work our way down the component tree. We may even incorrectly copy a key-value, ruining data.
Can we improve this? As a React dev, your first instinct may be to use props to pass down sendLog
and our data object. That works. But what if the promo banner in our example is nested in a subsection of a subsection? Now you are “Prop-drilling” sendLog
and the data props through components that aren’t even logging.
React’s Context API allows us to share information between parent and child components no matter how far apart on the component tree without Prop-drilling. Let’s create a new Log component that uses the Context API to share our data props! The Log component also can contain our sendLog
function and logging logic, thereby keeping our substantive code separate from our logging code. That’s Separation of Concerns!
To correctly share data props, we need the Log component to be a Provider of its data props to other child Log components somewhere down the component tree. Also, we need Log to be a Consumer of parent Log component data props. Here’s a skeleton:
import React, { Component } from 'react';
import { sendLog } from './utils';
const Context = React.createContext('');
export const LogContext = Context;
class Log extends Component {
componentDidMount() {
// some code that calls sendLog
}
render() {
const { children, ...directProps } = this.props;
return (
<LogContext.Consumer>
{(consumedProps) => {
const combinedProps = { ...consumedProps, ...directProps };
return <LogContext.Provider value={combinedProps}>{children}</LogContext.Provider>;
}}
</LogContext.Consumer>
);
}
}
export default Log;
We use LogContext.Consumer
to consume data props from all parent Log components and then, after combining them with directly-passed data props, we pass the combinedProps
down to other Log components through LogContext.Provider
. No more Prop-drilling or manual data copying!
Smarter Impressions
Now that we can share data effectively, let’s add impression logging functionality. We want data sharing and impression logging to work with this example usage:
<Log page="home">
<Log logImpression section="welcome">
Welcome
</Log>
</Log>
In the example code, we want to set a data tag on the home page, page="home"
. But we aren’t logging a page impression. We want to log an impression of the welcome section. To do so, we pass a logImpression
prop into the Log component wrapping the welcome section (along with another data prop, section="welcome"
).
To make the Log component send an impression of the welcome section, we could check in Log’s componentDidMount
whether logImpression
exists and, if it does, call sendLog
. That way, when Log and its wrapped child (the welcome section) mount, an impression is logged. Not bad. But what if the welcome section is mounted off the screen? Say at the bottom of a tall page where the user has to scroll down to actually see it:
<Log page="home">
<div style={{ height: "2000px" }}>I'm so tall!</div>
<Log logImpression section="welcome">
Welcome!
</Log>
</Log>
Obviously, we don’t want to log the impression until the user actually scrolls the welcome section into view. To create smarter impression logging, let’s use the Intersection Observer API.
First, in Log’s componentDidMount
, we check if the logImpression
prop is true. If it is true, we call the setupObserver
method to instantiate an Intersection Observer instance:
componentDidMount() {
if (this.props.logImpression) {
this.setupObserver();
}
}
setupObserver() {
this.observer = new IntersectionObserver(this.observerCallback, {
root: null,
rootMargin: '0px',
threshold: 0,
});
// add code to observe our wrapped child element with this.observer
}
When creating the instance, we set the root
to null
, meaning we are checking when our observed element intersects the viewport, i.e., enters the screen. We also set the rootMargin
and threshold
params to 0
to avoid any offsets.
Next, in Log’s render
method, we wrap the Log component’s children
in a div
element. We need the additional div
because we need to use React Ref to find our observed child element on the DOM. The div
provides the ref
:
constructor(props) {
super(props);
this.logDOMElementRef = React.createRef();
}
render() {
const { children, logImpression, ...directProps } = this.props;
return (
<LogContext.Consumer>
{(consumedProps) => {
this.combinedProps = { ...consumedProps, ...directProps };
return (
<Context.Provider value={this.combinedProps}>
<div style={{ display: 'contents' }} ref={this.logDOMElementRef}>
{children}
</div>
</Context.Provider>
);
}}
</LogContext.Consumer>
);
}
We have to be careful here because we don’t want the additional div
to impact UI layout. To prevent that, we set the div
’s CSS display property to contents
.
Now that we have our ref
, we find the Log component’s first visible child element by finding the first child whose offsetParent
is not null
. This is important because an element’s offsetParent
is null
if its display
property is none
. Once we have found our visible child element, we tell our observer to observe it! Let’s update setupObserver
accordingly:
setupObserver() {
this.observer = new IntersectionObserver(this.observerCallback, {
root: null,
rootMargin: '0px',
threshold: 0,
});
const wrappedDOMElements = this.logDOMElementRef?.current?.childNodes;
const firstVisibleElement = find(wrappedDOMElements, (el) => el.offsetParent !== null);
if (firstVisibleElement) {
this.observer.observe(firstVisibleElement);
}
}
An observer’s first argument is a callback. Ours is conspicuously named observerCallback
. When an observed child element comes into view, observerCallback
is called:
constructor(props) {
super(props);
this.logDOMElementRef = React.createRef();
this.state = { isInViewport: false };
this.hasImpressionAlreadyBeenLogged = false;
this.observerCallback = this.observerCallback.bind(this);
}
observerCallback(entries) {
const entry = entries[0];
if (entry !== undefined && this.state.isInViewport !== entry.isIntersecting) {
this.setState(() => ({
isInViewport: entry.isIntersecting,
}));
}
}
In observerCallback
, we check if our entry, i.e. observed child, is intersecting the viewport and update our Log component’s state isInViewport
value accordingly. The state update triggers componentDidUpdate, which checks if the isInViewport
value is now true
:
componentDidUpdate() {
if (
this.props.logImpression &&
this.state.isInViewport &&
!this.hasImpressionAlreadyBeenLogged
) {
sendLog(this.combinedProps);
this.hasImpressionAlreadyBeenLogged = true;
}
}
If isInViewport
is true, and an impression has not previously been logged (this.hasImpressionAlreadyBeenLogged
is still false
), we call sendLog
with this.combinedProps
.
Since we are using the Context API, our Log component has inherited the page="home"
data tag prop from its parent Log component. Thus, this.combinedProps
already contains the page tag along with the directly-passed section="welcome"
tag. We didn’t have to copy anything, and an impression is sent when the component is scrolled into view with all the data we want!
Clean Event Logging
We’ve improved data sharing and impression logging. Let’s see if we can improve logging events like button clicks, typing in an input, and choosing a dropdown option.
Traditionally, event logging has been done by adding logging code into event handler methods alongside the substantive code. Remember our promo banner? Say it has a close button on it. Following the traditional way, we’ll have an onClick
property on the button component, with a handleClick
event handler as its value. In handleClick
, we’ll have code that hides the banner and calls sendLog
. We would want the click data to contain the context of where the button is on the page. So we would call:
sendLog({ page: 'home', section: 'welcome section', component: 'banner', action: 'click', elementType: 'button', elementName: 'promo-close-button' });
Examining the sendLog
data, we may ask: what are the data tags that are actually unique to this close button? Just one: elementName: 'promo-close-button'
. The data tags page: 'home'
, section: 'welcome section'
, and component: 'banner'
all provide context but aren’t used only for the button. Meanwhile, action: 'click'
and elementType: 'button'
are likely the same for all buttons everywhere in our application. So ideally we would just want to pass elementName: 'promo-close-button'
directly into our close button:
<Log page="home">
<div id="home">
Home! {/* more home screen jsx */}
<Log logImpression section="welcome">
<div id="welcome-section">
Welcome! {/* more welcome section jsx */}
<Log logImpression component="banner" elementName="promo-banner">
<div id="promo-banner">
Promo! {/* more banner jsx */}
<Button
text="Close!"
onClick={this.handleClick}
logEventProps={{
elementName: 'promo-close-button',
}}
/>
</div>
</Log>
</div>
</Log>
</div>
</Log>
*The ugly nesting is just so you can see everything together. In a real implementation, each div would likely be its own component with a Log component wrapping it.
In the code, we pass logEventProps
directly into the Button component, with the one data tag we need, elementName: 'promo-close-button'
. In order to make this work, we must prepare our Button component to use logEventProps
. We wrap Button with a new component, LogEvent:
export default function Button({ logEventProps, onClick, text }) {
return (
<LogEvent
logEventProps={logEventProps}
actionProps={{ onClick: { action: 'click' } }}
elementType="button"
>
<button onClick={onClick}>{text}</button>
</LogEvent>
);
}
In our new LogEvent component, we specify that the Button component’s data tag elementType
is always 'button'
. Further, on an onClick
event, the Button’s action
tag is always 'click'
. By placing these tags in the LogEvent component that wraps Button, we never have to copy them again when we log a click of a Button instance anywhere in our application!
Well how does a basic LogEvent component work?
import React from 'react';
import { forEach } from 'lodash';
import Log, { LogContext } from './log';
import { sendLog } from './utils';
function getEventHandlers(child, consumedProps, actionProps) {
// create modified event handlers that log
}
export default function LogEvent(props) {
const { elementType, actionProps, logEventProps, children } = props;
if (!logEventProps || !actionProps) {
return children;
}
return (
<Log elementType={elementType} {...logEventProps}>
<LogContext.Consumer>
{(consumedProps) =>
React.Children.map(children, (child) =>
React.cloneElement(
child,
getEventHandlers(child, consumedProps, actionProps)
)
)
}
</LogContext.Consumer>
</Log>
);
}
The first thing we do in the LogEvent component is check if logEventProps
and actionProps
are present. Since our logging functionality requires logEventProps
and actionProps
to work, if either is missing, we simply return the unmodified child component (e.g. button element).
If the required props exist, we wrap our entire LogEvent in a Log component. Why? We want our Log component to inherit data tag props from parent Log components via the Context API and combine them with the elementType
and other logEventProps
passed into LogEvent. This eliminates the need for data tag copying and provides all the contextual data tags for our event log. Also, if we want to log an impression of the child component (e.g., the button), we can do that by passing the logImpression flag in logEventProps
.
Once Log has created the data props object, we need to consume it with LogContext.Consumer
. Now, we have all the data tags we need!
Next, we address the fact that the children prop is plural. In our example, we wrapped just one element, a button. But, we may wrap multiple components with LogEvent (e.g. radio button group). Thus, we use React.Children.map to iterate and return the wrapped child or children. We don’t want to simply return an unmodified wrapped child. What we want to do is inject our logging code into the child’s event handler! For that, we are going to use React.cloneElement.
The cloneElement
function returns a copy of an element. You can use it to add or modify the copied element’s props. Perfect! Since we want to add logging code to the child’s event handler prop, we need to modify the handler and pass it to cloneElement
. Our getEventHandlers
function contains our modification code:
function getEventHandlers(child, consumedProps, actionProps) {
const eventHandlers = {};
forEach(actionProps, (specificHandlerProps, eventHandlerName) => {
eventHandlers[eventHandlerName] = (...eventHandlerArgs) => {
child.props[eventHandlerName]?.apply(child, eventHandlerArgs);
sendLog({ ...consumedProps, ...specificHandlerProps });
};
});
return eventHandlers;
}
In getEventHandlers
, we initialize an empty eventHandlers
object. It will store all the event handler functions we will modify. For our example, in LogEvent’s actionProps
, we specified a single event handler prop function’s name, onClick
. In some cases however, we may have multiple event handlers we may want to modify. Therefore, we loop through each of our actionProps
.
The key of each of the actionProps
is the event handler name (e.g. onClick
). The value is an object containing additional data tags specific to that event handler. In our example, the value for specificHandlerProps
is { action: 'click' }
. In our loop, we use the JS apply function to ensure that the cloned child’s original event handler still gets called with all of its arguments. After all, we want our close button to still close the promo banner! Finally, we also add our sendLog
call with the data tags from our consumed props and specific handler props.
Now that we’re done, when someone clicks our close button, it will close the promo banner and log all of the data we want. Eureka! Also, since we’ve wrapped our Button component, it is ready for easy-to-use logging anywhere in our codebase.
Thanks to LogEvent there is no more error-prone data copying for each logged event or mixing substantive code with logging code in event handlers. We can also easily use LogEvent to wrap other action components like selects and inputs by passing in different actionProps
!
By making it easier to implement impressions and event logging, we saw a 66% decrease in log code length. Also, it takes devs a quarter of the time to implement logs.
Part II Coming Soon
In the next installment, we will examine how we abstracted the library for use by any team. We will also look at how we added two powerful features: keeping log history and building a live log viewer. Finally, we will discuss the library’s adoption and impact at Slack. Stay tuned!