If you have ever used Google Analytics, you know it isn’t the prettiest interface to use. It gets the job done, sure, but I’m not a huge fan of how it looks, nor the color palette. I mean, look at this:

It’s just so boring and bland — I need more color in my life than this. I also want some more customization from Google Analytics that it just doesn’t provide. Luckily, we’re software developers, so we can build our own version of Google Analytics to our standards!

Google APIs

Lucky for us, Google provides a slew of different APIs for us to use in our projects. We just need to set this up in our Google Developer account.

Create a new project

First we’ll need to create a new project by clicking on the projects selection in the top left:

Then create a new project and name it whatever you’d like.

Add Google Analytics API

Once we create our project, we need to add some services so that we can use the Google Analytics API. To do this, we’ll click the Enable APIs and Services at the top of the page.

Once at the APIs & Services page, we’re going to search for “google analytics api” to add that to our project. Do not add the Google Analytics Reporting API. This is not the API we want.

Create a service account

After we add the Analytics API, we need to create a service account so that our app can access the API. To do this, let’s head over to the credentials section from the console homescreen.

Once there, click on the Create Credentials dropdown and select Service Account Key

Now set the options you see to the following (apart from Service account name — you can name that whatever you’d like).

Once you click Create, a JSON file will be generated. Save this in a known location, as we’ll need part of the contents.

In that JSON file, find the client email and copy it. Then head over to Google Analytics and add a new user to your view. Do this by first clicking on the gear in the lower left-hand corner, then go to User Management in the view section

Here, add a new user by clicking the big blue plus in the upper right-hand corner and selecting Add users.

Paste in the client email from your JSON file, and make sure Read & Analyze is checked off in permissions. These are the only permissions we want to give this account.

Finally, we want to get the view ID for later. From your admin settings, go to view settings and copy the View ID for later (better yet, just keep this in a separate open tab).

Your Google APIs should be ready to go now!

Back end

For our back end, we will be using Node.js. Let’s get started by setting up our project! For this I will be using yarn as my package manager, but npm should work fine as well.

Setup

First, let’s run yarn init to get our structure started. Enter the name, description, and such that you like. Yarn will set our entry point as server.js rather than index.js , so this is what that will refer to from here on. Now let’s add our dependencies:

$ yarn add cors dotenv express googleapis

We will also want to add concurrently and jest to our dev dependencies since we will be using this in our scripts.

$ yarn add -D concurrently

Speaking of which, let’s set those up now. In our package.json , we’ll want to set our scripts to be:

"scripts": { "test_server": "jest ./ --passWithNoTests", "test_client": "cd client && yarn test", "test": "concurrently \"yarn test_server\" \"yarn test_client\"", "start": "concurrently \"npm run server\" \"npm run client\"", "server": "node server.js", "client": "cd client && npm start", "build": "cd client && yarn build" },

Finally, we will want to create a .env file to store our secrets and some configuration. Here’s what we’ll want to add to it:

CLIENT_EMAIL="This is the email in your json file from google" PRIVATE_KEY="This is also in the json file" VIEW_ID="The view id from google analytics you copied down earlier" SERVER_PORT=3001 // or whatever port you'd like NODE_ENV="dev"

Great — now we’re basically ready to start developing our server. If you want, you can add eslint to your dependencies now before getting started (which I would recommend).

Server

Let’s get started on this server file now, shall we? First, let’s create it with touch server.js . Now open that up in your favorite editor. At the top of this, we’ll want to define some things:

require('dotenv').config(); // Server const express = require('express'); const cors = require('cors'); const app = express(); app.use(cors()); const server = require('http').createServer(app); // Config const port = process.env.SERVER_PORT; if (process.env.NODE_ENV === 'production') { app.use(express.static('client/build')); }

Here we’re going to load in our .env by using require('dotenv').config() , which handles the hard work for us. This loads all our variables into process.env for later use.

Next, we define our server, for which we use express . We add cors to our Express app so we can access it from our front end later. Then, we wrap our app in require('http').createServer so that we can add some fun stuff with Socket.IO later on.

Finally, we do some configuration by setting a global constant port to shorthand this later and change our static path based on our NODE_ENV variable.

Now let’s make our server listen to our port by adding this to the bottom of our server.js file:

server.listen(port, () => { console.log(`Server running at localhost:${port}`); });

Awesome! That’s all we can really do for our server until we develop our Google APIs library.

Analytics library

Back at our terminal, let’s create a new directory called libraries/ using mkdir libraries and create our analytics handler. I will call this gAnalytics.js , which we can create using touch libraries/gAnalytics.js and then switching back to the editor.

In gAnalytics.js , let’s define some configuration:

// Config const clientEmail = process.env.CLIENT_EMAIL; const privateKey = process.env.PRIVATE_KEY.replace(new RegExp('\\\

'), '

'); const scopes = ['https://www.googleapis.com/auth/analytics.readonly'];

We need to pull in our client email and private key (which were pulled from the JSON credential file provided by Google API Console) from the process.env , and we need to replace any \

s in our private key (which is how dotenv will read it in) and replace them with

. Finally, we define some scopes for Google APIs. There are quite a few different options here, such as:

https://www.googleapis.com/auth/analytics to view and manage the data https://www.googleapis.com/auth/analytics.edit to edit the management entities https://www.googleapis.com/auth/analytics.manage.users to manage the account users and permissions

And quite a few more, but we only want read-only so that we don’t expose too much with our application.

Now let’s set up Google Analytics by using those variables:

// API's const { google } = require('googleapis'); const analytics = google.analytics('v3'); const viewId = process.env.VIEW_ID; const jwt = new google.auth.JWT({ email: clientEmail, key: privateKey, scopes, });

Here we just require google to create analytics and jwt . We also pull out the viewId from process.env . We created a JWT here to authorize ourselves later on when we need some data. Now we need to create some functions to actually retrieve the data. First we’ll create the fetching function:

async function getMetric(metric, startDate, endDate) { await setTimeout[Object.getOwnPropertySymbols(setTimeout)[0]]( Math.trunc(1000 * Math.random()), ); // 3 sec const result = await analytics.data.ga.get({ auth: jwt, ids: `ga:${viewId}`, 'start-date': startDate, 'end-date': endDate, metrics: metric, }); const res = {}; res[metric] = { value: parseInt(result.data.totalsForAllResults[metric], 10), start: startDate, end: endDate, }; return res; }

There’s a bit to this one, so let’s break it down. First, we make this async so that we can fetch many metrics at once. There’s a quote imposed by Google, however, so we need to add a random wait to it using

await setTimeout[Object.getOwnPropertySymbols(setTimeout)[0]]( Math.trunc(1000 * Math.random()), );

This would very likely introduce scalability issues if you have many users trying to load data, but I’m just one person, so it works for my needs.

Next, we fetch the data using analytics.data.ga.get , which will return a rather large object with a ton of data. We don’t need all of it, so we just take out the important bit: result.data.totalsForAlResults[metric] . This is a string, so we convert it to an int and return it in an object with our start and end dates.

Next, let’s add a way of batch-getting metrics:

function parseMetric(metric) { let cleanMetric = metric; if (!cleanMetric.startsWith('ga:')) { cleanMetric = `ga:${cleanMetric}`; } return cleanMetric; } function getData(metrics = ['ga:users'], startDate = '30daysAgo', endDate = 'today') { // ensure all metrics have ga: const results = []; for (let i = 0; i < metrics.length; i += 1) { const metric = parseMetric(metrics[i]); results.push(getMetric(metric, startDate, endDate)); } return results; }

This will make it easy for us to request a bunch of metrics all at once. This just returns a list of getMetric promises. We also add in a way to clean up the metric names passed to the function using parseMetric , which just adds ga: to the front of the metric if it isn’t there already.

Finally, export getData at the bottom and our library is good to go.

module.exports = { getData };

Tying it all in

Now let’s combine our library and server by adding some routes. In server.js , we’ll add the following path:

app.get('/api', (req, res) => { const { metrics, startDate, endDate } = req.query; console.log(`Requested metrics: ${metrics}`); console.log(`Requested start-date: ${startDate}`); console.log(`Requested end-date: ${endDate}`); Promise.all(getData(metrics ? metrics.split(',') : metrics, startDate, endDate)) .then((data) => { // flatten list of objects into one object const body = {}; Object.values(data).forEach((value) => { Object.keys(value).forEach((key) => { body[key] = value[key]; }); }); res.send({ data: body }); console.log('Done'); }) .catch((err) => { console.log('Error:'); console.log(err); res.send({ status: 'Error getting a metric', message: `${err}` }); console.log('Done'); }); });

This path allows our client to request a list of metrics (or just one metric) and then return all the data once it’s retrieved, as we can see by Promise.all . This will wait until all promises in the given list are completed or until one fails.

We can then add a .then that takes a data param. This data param is a list of data objects that we created in gAnalytics.getData , so we iterate through all the objects and combine them into a body object. This object is what will be sent back to our client in the form res.send({data: body}); .

We’ll also add a .catch to our Promise.all , which will send back an error message and log the error.

Now let’s add the api/graph/ path, which will be used for… well, graphing. This will be very similar to our /api path but with its own nuances.

app.get('/api/graph', (req, res) => { const { metric } = req.query; console.log(`Requested graph of metric: ${metric}`); // 1 week time frame let promises = []; for (let i = 7; i >= 0; i -= 1) { promises.push(getData([metric], `${i}daysAgo`, `${i}daysAgo`)); } promises = [].concat(...promises); Promise.all(promises) .then((data) => { // flatten list of objects into one object const body = {}; body[metric] = []; Object.values(data).forEach((value) => { body[metric].push(value[metric.startsWith('ga:') ? metric : `ga:${metric}`]); }); console.log(body); res.send({ data: body }); console.log('Done'); }) .catch((err) => { console.log('Error:'); console.log(err); res.send({ status: 'Error', message: `${err}` }); console.log('Done'); }); });

As you can see, we still rely on gAnalytics.getData and Promise.all , but instead, we get the data for the last seven days and smash that all into one list to send back in the body.

That’s it for our server now. Easy peasy, wouldn’t you say? Now for the real beast, the front end.

Front end

Front ends are a ton of fun but can be quite a challenge to develop and design. Let’s give it a shot, though! For our front end, we will be using the React framework in all its glory. I recommend getting up, going for a walk, maybe getting a glass of water before we get started.

You didn’t do any of those things, did you? Alright, fine, let’s get started.

Setup and structure

First, we need to create our boilerplate. We’re going to use the create-react-app boilerplate as it’s always a great starting point. So, run create-react-app client and let it do it’s thing. Once finished, we’ll install some dependencies that we’ll need. Make sure you cd into the client/ folder and then run $ yarn add @material-ui/core prop-types recharts .

Again, set up eslint here if you’d like it. Next we’ll clean up src/App.js before moving on to the structure. Open up src/App.js and remove everything so that the only thing left is:

import React from 'react'; import './App.css'; function App() { return ( <div className="App"> </div> ); } export default App;

We also want to delete serviceWorker.js and remove it from src/index.js .

For structure, we’re just going to set up everything right away and develop afterwards. Here’s how our src folder is going to look (which will make sense later):

├── App.css ├── App.js ├── App.test.js ├── components │ ├── Dashboard │ │ ├── DashboardItem │ │ │ ├── DashboardItem.js │ │ │ └── DataItems │ │ │ ├── index.js │ │ │ ├── ChartItem │ │ │ │ └── ChartItem.js │ │ │ └── TextItem │ │ │ └── TextItem.js │ │ └── Dashboard.js │ └── Header │ └── Header.js ├── index.css ├── index.js ├── theme │ ├── index.js │ └── palette.js └── utils.js

Create all of those files and folders, as we will be editing them to build our app. From here, every file reference is relative to the src/ folder.

Components

App and theme

Let’s start back at App . We need to edit this to look like the below:

import React from 'react'; import './App.css'; import Dashboard from './components/Dashboard/Dashboard'; import { ThemeProvider } from '@material-ui/styles'; import theme from './theme'; import Header from './components/Header/Header'; function App() { return ( <ThemeProvider theme={theme}> <div className="App"> <Header text={"Analytics Dashboard"}/> <br/> <Dashboard /> </div> </ThemeProvider> ); } export default App;

This will pull in the necessary components and create our theme provider. Next, let’s edit that theme. Open up theme/index.js and add the following:

import { createMuiTheme } from '@material-ui/core'; import palette from './palette'; const theme = createMuiTheme({ palette, }); export default theme;

Next open up theme/palette.js and add the following:

import { colors } from '@material-ui/core'; const white = '#FFFFFF'; const black = '#000000'; export default { black, white, primary: { contrastText: white, dark: colors.indigo[900], main: colors.indigo[500], light: colors.indigo[100] }, secondary: { contrastText: white, dark: colors.blue[900], main: colors.blue['A400'], light: colors.blue['A400'] }, text: { primary: colors.blueGrey[900], secondary: colors.blueGrey[600], link: colors.blue[600] }, background: { primary: '#f2e1b7', secondary: '#ffb3b1', tertiary: '#9ac48d', quaternary: '#fdae03', quinary: '#e7140d', }, };

The above will all let us use theme within our components for different styling options. We also define our theme colors, which you can change to your heart’s content. I liked the pastel-like feel of these.

Header

Next, let’s create our header. Open up components/Header/header.js and add in this:

import React from 'react'; import PropTypes from 'prop-types'; import { withStyles } from '@material-ui/core/styles'; import Paper from '@material-ui/core/Paper'; import AppBar from '@material-ui/core/AppBar'; const styles = (theme) => ({ header: { padding: theme.spacing(3), textAlign: 'center', color: theme.palette.text.primary, background: theme.palette.background.primary, }, }); export const Header = (props) => { const { classes, text } = props; return ( <AppBar position="static"> <Paper className={classes.header}>{text}</Paper> </AppBar> ); }; Header.propTypes = { classes: PropTypes.object.isRequired, text: PropTypes.string.isRequired, }; export default withStyles(styles)(Header);

This will create a horizontal bar at the top of our page, with the text being whatever we set the prop to. It also pulls in our styling and uses that to make it look oh so good.

Dashboard

Moving on, let’s now work on components/Dashboard/Dashboard.js . This is a much simpler component and looks like this:

import React from 'react'; import PropTypes from 'prop-types'; import { withStyles } from '@material-ui/core/styles'; import Grid from '@material-ui/core/Grid'; import DashboardItem from './DashboardItem/DashboardItem'; import { isMobile } from '../../utils'; const styles = () => ({ root: { flexGrow: 1, overflow: 'hidden', }, }); const Dashboard = (props) => { const { classes } = props; return ( <div className={classes.root}> <Grid container direction={isMobile ? 'column' : 'row'} spacing={3} justify="center" alignItems="center"> <DashboardItem size={9} priority="primary" metric="Users" visual="chart" type="line" /> <DashboardItem size={3} priority="secondary" metric="Sessions"/> <DashboardItem size={3} priority="primary" metric="Page Views"/> <DashboardItem size={9} metric="Total Events" visual="chart" type="line"/> </Grid> </div> ); }; Dashboard.propTypes = { classes: PropTypes.object.isRequired, }; export default withStyles(styles)(Dashboard);

Here we add a few Dashboard Item s as examples with different metrics. These metrics are from the Google API’s Metrics & Dimensions Explore. We also need to create a utils.js file containing this:

export function numberWithCommas(x) { return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); } export const isMobile = window.innerWidth <= 500;

This will tell us if the user is on mobile or not. We want a responsive app, so we need to know whether the user is on mobile. Alright, let’s move on.

DashboardItem

Next up, we have the DashboardItem , which we will edit Dashboard/DashboardItem/DashboardItem.js to create. Add this to that file:

import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { withStyles } from '@material-ui/core/styles'; import Paper from '@material-ui/core/Paper'; import Grid from '@material-ui/core/Grid'; import { TextItem, ChartItem, RealTimeItem } from './DataItems'; import { numberWithCommas, isMobile } from '../../../utils'; const styles = (theme) => ({ paper: { marginLeft: theme.spacing(1), marginRight: theme.spacing(1), paddingTop: theme.spacing(10), textAlign: 'center', color: theme.palette.text.primary, height: 200, minWidth: 300, }, chartItem: { paddingTop: theme.spacing(1), height: 272, }, mainMetric: { background: theme.palette.background.quaternary, }, secondaryMetric: { background: theme.palette.background.secondary, }, defaultMetric: { background: theme.palette.background.tertiary, }, }); class DashboardItem extends Component { constructor(props) { super(props); const { classes, size, metric, priority, visual, type, } = this.props; this.state = { classNames: classes, size, metric, priority, visual, type, data: 'No data', }; } componentDidMount() { this.getMetricData(); this.getClassNames(); } getMetricData() { const { visual, metric } = this.state; const strippedMetric = metric.replace(' ', ''); let url; if (visual === 'chart') { url = `http://localhost:3001/api/graph?metric=${strippedMetric}`; } else { url = `http://localhost:3001/api?metrics=${strippedMetric}`; } fetch(url, { method: 'GET', mode: 'cors', }) .then((res) => (res.json())) .then((data) => { let value; let formattedValue; if (visual === 'chart') { value = data.data[strippedMetric]; formattedValue = value; } else { try { value = strippedMetric.startsWith('ga:') ? data.data[strippedMetric] : data.data[`ga:${strippedMetric}`]; formattedValue = numberWithCommas(parseInt(value.value, 10)); } catch (exp) { console.log(exp); formattedValue = "Error Retrieving Value" } } this.setState({ data: formattedValue }); }); } getClassNames() { const { priority, visual } = this.state; const { classes } = this.props; let classNames = classes.paper; switch (priority) { case 'primary': classNames = `${classNames} ${classes.mainMetric}`; break; case 'secondary': classNames = `${classNames} ${classes.secondaryMetric}`; break; default: classNames = `${classNames} ${classes.defaultMetric}`; break; } if (visual === 'chart') { classNames = `${classNames} ${classes.chartItem}`; } this.setState({ classNames }); } getVisualComponent() { const { data, visual, type } = this.state; let component; if (data === 'No data') { component = <TextItem data={data} />; } else { switch (visual) { case 'chart': component = <ChartItem data={data} xKey='start' valKey='value' type={type} />; break; default: component = <TextItem data={data} />; break; } } return component; } render() { const { classNames, metric, size, } = this.state; const visualComponent = this.getVisualComponent(); return ( <Grid item xs={(isMobile || !size) ? 'auto' : size} zeroMinWidth> <Paper className={`${classNames}`}> <h2>{ metric }</h2> {visualComponent} </Paper> </Grid> ); } } DashboardItem.propTypes = { size: PropTypes.number, priority: PropTypes.string, visual: PropTypes.string, type: PropTypes.string, classes: PropTypes.object.isRequired, metric: PropTypes.string.isRequired, }; DashboardItem.defaultProps = { size: null, priority: null, visual: 'text', type: null, }; export default withStyles(styles)(DashboardItem);

This component is pretty massive, but it’s the bread and butter of our application. To sum it up in a few sentences, this component is how we can have a highly customizable interface. With this component, depending on the props passed, we can change the size, color, and type of visual. The DashboardItem component also fetches the data for itself and then passes it to its visual component.

We do have to create those visual components, though, so let’s do that.

Visual components ( DataItems )

We need to create both the ChartItem and TextItem for our DashboardItem to render properly. Open up components/Dashboard/DashboardItem/DataItems/TextItem/TextItem.js and add the following to it

import React from 'react'; import PropTypes from 'prop-types'; export const TextItem = (props) => { const { data } = props; let view; if (data === 'No data') { view = data; } else { view = `${data} over the past 30 days` } return ( <p> {view} </p> ); }; TextItem.propTypes = { data: PropTypes.string.isRequired, }; export default TextItem;

This one is super simple — it basically displays the text passed to it as the data prop. Now let’s do the ChartItem by opening up components/Dashboard/DashboardItem/DataItems/ChartItem/ChartItem.js and adding this into it:

import React from 'react'; import PropTypes from 'prop-types'; import { ResponsiveContainer, LineChart, XAxis, YAxis, CartesianGrid, Line, Tooltip, } from 'recharts'; export const ChartItem = (props) => { const { data, xKey, valKey } = props; return ( <ResponsiveContainer height="75%" width="90%"> <LineChart data={data}> <XAxis dataKey={xKey} /> <YAxis type="number" domain={[0, 'dataMax + 100']} /> <Tooltip /> <CartesianGrid stroke="#eee" strokeDasharray="5 5" /> <Line type="monotone" dataKey={valKey} stroke="#8884d8" /> </LineChart> </ResponsiveContainer> ); }; ChartItem.propTypes = { data: PropTypes.array.isRequired, xKey: PropTypes.string, valKey: PropTypes.string, }; ChartItem.defaultProps = { xKey: 'end', valKey: 'value', }; export default ChartItem;

This will do exactly what it sounds like it does: render a chart. This uses that api/graph/ route we added to our server.

Finished!

At this point, you should be good to go with what we have! All you need to do is run yarn start from the topmost directory, and everything should boot up just fine.

Real time

One of the best parts of Google Analytics is the ability to see who is using your site in real time. We can do that, too! Sadly, Google APIs has the Realtime API as a closed beta, but again, we’re software developers! Let’s make our own.

Back end

Adding Socket.IO

We’re going to use Socket.IO for this since it allows for real-time communications between machines. First, add Socket.IO to your dependencies with yarn add socket.io . Now, open up your server.js file and add the following to the top of it:

const io = require('socket.io').listen(server);

You can add this just below the server definition. And at the bottom, but above the server.listen , add the following:

io.sockets.on('connection', (socket) => { socket.on('message', (message) => { console.log('Received message:'); console.log(message); console.log(Object.keys(io.sockets.connected).length); io.sockets.emit('pageview', { connections: Object.keys(io.sockets.connected).length - 1 }); }); });

This will allow our server to listen for sockets connecting to it and sending it a message. When it receives a message, it will then emit a 'pageview' event to all the sockets (this probably isn’t the safest thing to do, but we’re only sending out the number of connections, so it’s nothing important).

Create public script

To have our clients send our server a message, they need a script! Let’s create a script in client/public called realTimeScripts.js , which will contain:

const socket = io.connect(); socket.on('connect', function() { socket.send(window.location); });

Now we just need to reference these two scripts in any of our webpages, and the connection will be tracked.

<script src="/socket.io/socket.io.js"></script> <script src="realTimeScripts.js"></script>

The /socket.io/socket.io.js is handled by the installation of socket.io , so there is no need to create this.

Front end

Create a new component

To view these connections, we need a new component. Let’s first edit DashboardItem.js by adding the following to getMetricData :

//... const strippedMetric = metric.replace(' ', ''); // Do not need to retrieve metric data if metric is real time, handled in component if (metric.toUpperCase() === "REAL TIME") { this.setState({ data: "Real Time" }) return; } //...

This will set our state and return us out of the getMetricData function since we don’t need to fetch anything. Next, let’s add the following to getVisualComponent :

//... component = <TextItem data={data} />; } else if (data === 'Real Time') { component = <RealTimeItem /> } else { switch (visual) { //...

Now our visual component will be set to our RealTimeItem when the metric prop is "Real Time" .

Now we need to create the RealTimeItem component. Create the following path and file: Dashboard/DashboardItem/DataItems/RealTimeItem/RealTimeItem.js . Now add the following to it:

import React, { useState } from 'react'; import openSocket from 'socket.io-client'; const socket = openSocket('http://localhost:3001'); const getConnections = (cb) => { socket.on('pageview', (connections) => cb(connections.connections)) } export const RealTimeItem = () => { const [connections, setConnections] = useState(0); getConnections((conns) => { console.log(conns); setConnections(conns); }); return ( <p> {connections} </p> ); }; export default RealTimeItem;

This will add a real-time card to our dashboard.

And we’re finished!

You should now have a fully functional dashboard that looks like this:

This is meant to be a highly extendable dashboard where you can add new data items in a similar way to how we added the real-time item. I will continue to develop this out further, as I have thought of several other things I want to do with this, including an Add Card button, changing sizes, different chart types, adding dimensions, and more! If you would like me to continue writing about this dashboard, let me know! Finally, if you would like to see the source code, you can find the repo here.

Full visibility into production React apps Debugging React applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, Debugging React applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket LogRocket is like a DVR for web apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more. The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores. Modernize how you debug your React apps — start monitoring for free.