Merge pull request #518 from sharetribe/basic-analytics

Basic analytics
This commit is contained in:
Kimmo Puputti 2017-10-26 14:25:29 +03:00 committed by GitHub
commit d0b728bfd6
20 changed files with 285 additions and 33 deletions

View file

@ -14,3 +14,4 @@ Documentation for specific topics can be found in the following files:
- [Error logging with Sentry](sentry.md)
- [CI](ci.md)
- [Static pages](static-pages.md)
- [Analytics](analytics.md)

43
docs/analytics.md Normal file
View file

@ -0,0 +1,43 @@
# Analytics
The application supports tracking page views with pluggable analytics
handlers.
## Google Analytics
Google Analytics (GA) is supported by default. The enable GA, set the
GA tracker ID to the environment variable
`REACT_APP_GOOGLE_ANALYTICS_ID`;
## Custom analytics handler
If you want to add a new analytics library, you can do as follows:
1. Add the analytics library script
If the analytics library has an external script, add the library
script tag to the [src/public/index.html](../public/index.html)
file. If you need more control, see how the GA script is added in
[server/renderer.js](../server/renderer.js).
1. Create a handler
To track page views, create a custom handler e.g. in
[src/analytics/handlers.js](../src/analytics/handlers.js). The
handler should be a class that implements a `trackPageView(url)`
method.
Note that the `url` parameter might not be the same as in the URL
bar of the browser. It is the canonical form of the URL. For
example, in the listing page it doesn't have the dynamic title slug
in the middle. This allows unified analytics and correct tracking
of pages that can be accessed from multiple different URLs.
If you analytics library takes the page URL from the browser, you
might need to override that behavior to use the canonical URL that
is given to the method.
1. Initialise the handler
Initialise the handler in the `setupAnalyticsHandlers()` function
in [src/index.js](../src/index.js).

View file

@ -6,6 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<!--!meta-->
<!--!googleAnalyticsScript-->
<link rel="manifest" href="%PUBLIC_URL%/static/manifest.json">
<link rel="icon" sizes="32x32" type="image/x-icon" href="%PUBLIC_URL%/static/favicon.ico">
<link rel="icon" sizes="192x192" type="image/png" href="%PUBLIC_URL%/static/webapp-icon-192x192.png" />

View file

@ -108,6 +108,23 @@ exports.render = function(requestUrl, context, preloadedState) {
<script>window.__PRELOADED_STATE__ = ${JSON.stringify(serializedState)};</script>
`;
// We want to precicely control where the analytics script is
// injected in the HTML file so we can catch all events as early as
// possible. This is why we inject the GA script separately from
// react-helmet. This script also ensures that all the GA scripts
// are added only when the proper env var is present.
//
// See: https://developers.google.com/analytics/devguides/collection/analyticsjs/#alternative_async_tracking_snippet
const googleAnalyticsScript = process.env.REACT_APP_GOOGLE_ANALYTICS_ID
? `
<script>
window.ga=window.ga||function(){(ga.q=ga.q||[]).push(arguments)};ga.l=+new Date;
ga('create', '${process.env.REACT_APP_GOOGLE_ANALYTICS_ID}', 'auto');
</script>
<script async src="https://www.google-analytics.com/analytics.js"></script>
`
: '';
return template({
htmlAttributes: head.htmlAttributes.toString(),
title: head.title.toString(),
@ -115,6 +132,7 @@ exports.render = function(requestUrl, context, preloadedState) {
meta: head.meta.toString(),
script: head.script.toString(),
preloadedStateScript,
googleAnalyticsScript,
body,
});
};

View file

@ -5,8 +5,11 @@ import { connect } from 'react-redux';
import { Switch, Route, withRouter } from 'react-router-dom';
import { NotFoundPage } from './containers';
import { NamedRedirect } from './components';
import { locationChanged } from './ducks/Routing.duck';
import * as propTypes from './util/propTypes';
import * as log from './util/log';
import { canonicalRoutePath } from './util/routes';
import routeConfiguration from './routeConfiguration';
const { arrayOf, bool, object, func, shape, string } = PropTypes;
@ -34,10 +37,16 @@ const callLoadData = props => {
}
};
const handleLocationChanged = (dispatch, location) => {
const url = canonicalRoutePath(routeConfiguration(), location);
dispatch(locationChanged(location, url));
};
class RouteComponentRenderer extends Component {
componentDidMount() {
// Calling loadData on initial rendering (on client side).
callLoadData(this.props);
handleLocationChanged(this.props.dispatch, this.props.location);
}
componentWillReceiveProps(nextProps) {
@ -45,6 +54,7 @@ class RouteComponentRenderer extends Component {
// This makes it possible to use loadData as default client side data loading technique.
// However it is better to fetch data before location change to avoid "Loading data" state.
callLoadData(nextProps);
handleLocationChanged(nextProps.dispatch, nextProps.location);
}
render() {

View file

@ -0,0 +1,18 @@
import { LOCATION_CHANGED } from '../ducks/Routing.duck';
// Create a Redux middleware from the given analytics handlers. Each
// handler should have the following methods:
//
// - trackPageView(url): called when the URL is changed
export const createMiddleware = handlers => () => next => action => {
const { type, payload } = action;
if (type === LOCATION_CHANGED) {
const { canonicalUrl } = payload;
handlers.forEach(handler => {
handler.trackPageView(canonicalUrl);
});
}
next(action);
};

19
src/analytics/handlers.js Normal file
View file

@ -0,0 +1,19 @@
export class LoggingAnalyticsHandler {
trackPageView(url) {
console.log('Analytics page view:', url);
}
}
export class GoogleAnalyticsHandler {
constructor(ga) {
if (typeof ga !== 'function') {
throw new Error('Variable `ga` missing for Google Analytics');
}
this.ga = ga;
}
trackPageView(url) {
// https://developers.google.com/analytics/devguides/collection/analyticsjs/single-page-applications#tracking_virtual_pageviews
this.ga('set', 'page', url);
this.ga('send', 'pageview');
}
}

View file

@ -4,8 +4,10 @@ import Helmet from 'react-helmet';
import { withRouter } from 'react-router-dom';
import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
import classNames from 'classnames';
import routeConfiguration from '../../routeConfiguration';
import config from '../../config';
import { canonicalURL, metaTagProps } from '../../util/seo';
import { metaTagProps } from '../../util/seo';
import { canonicalRoutePath } from '../../util/routes';
import * as propTypes from '../../util/propTypes';
import facebookImage from '../../assets/saunatimeFacebook-1200x630.jpg';
@ -55,7 +57,7 @@ class PageComponent extends Component {
className,
rootClassName,
children,
history,
location,
intl,
logoutError,
scrollingDisabled,
@ -69,7 +71,6 @@ class PageComponent extends Component {
title,
twitterHandle,
twitterImages,
canonicalPath,
updated,
} = this.props;
@ -85,10 +86,10 @@ class PageComponent extends Component {
[css.scrollingDisabled]: scrollingDisabled,
});
const { pathname, search = '' } = history.location;
const pathWithSearch = `${pathname}${search}`;
const canonicalRootURL = config.canonicalRootURL;
const canonicalPath = canonicalRoutePath(routeConfiguration(), location);
const canonicalUrl = `${canonicalRootURL}${canonicalPath}`;
const siteTitle = config.siteTitle;
const schemaTitle = intl.formatMessage({ id: 'Page.schemaTitle' }, { siteTitle });
const schemaDescription = intl.formatMessage({ id: 'Page.schemaDescription' });
@ -122,7 +123,7 @@ class PageComponent extends Component {
title: metaTitle,
twitterHandle,
updated,
url: canonicalURL(pathWithSearch),
url: canonicalUrl,
locale: intl.locale,
});
@ -174,7 +175,7 @@ class PageComponent extends Component {
}}
>
<title>{title}</title>
<link rel="canonical" href={canonicalURL(canonicalPath || pathWithSearch)} />
<link rel="canonical" href={canonicalUrl} />
<meta httpEquiv="Content-Type" content="text/html; charset=UTF-8" />
<meta httpEquiv="Content-Language" content={intl.locale} />
{metaTags}
@ -204,7 +205,6 @@ PageComponent.defaultProps = {
description: null,
facebookImages: null,
twitterImages: null,
canonicalPath: null,
published: null,
schema: null,
tags: null,
@ -243,12 +243,12 @@ PageComponent.propTypes = {
title: string.isRequired, // page title
twitterHandle: string, // twitter handle
updated: string, // article:modified_time
canonicalPath: string, // link rel=canonical
// from withRouter
history: shape({
listen: func.isRequired,
}).isRequired,
location: object.isRequired,
// from injectIntl
intl: intlShape.isRequired,

View file

@ -49,6 +49,7 @@ const estimatedNightlyTransaction = (bookingStart, bookingEnd, unitPrice) => {
unitPrice: unitPrice,
quantity: new Decimal(nightCount),
lineTotal: totalPrice,
reversal: false,
},
],
},

View file

@ -28,7 +28,7 @@ describe('BookingDatesForm', () => {
);
expect(tree).toMatchSnapshot();
});
it('matches snapshot with selected dates', () => {
it('matches selected dates', () => {
const price = new Money(1099, 'USD');
const startDate = new Date(Date.UTC(2017, 3, 14));
const endDate = new Date(Date.UTC(2017, 3, 16));
@ -58,6 +58,7 @@ describe('BookingDatesForm', () => {
unitPrice: price,
quantity: new Decimal(2),
lineTotal: new Money(2198, 'USD'),
reversal: false,
},
]);
});

View file

@ -330,14 +330,6 @@ export class ListingPageComponent extends Component {
{ title, price: formattedPrice, siteTitle }
);
const canonicalPath = createResourceLocatorString(
'ListingPageCanonical',
routeConfiguration(),
{
id: listingId.uuid,
}
);
return (
<Page
logoutError={logoutError}
@ -348,7 +340,6 @@ export class ListingPageComponent extends Component {
description={description}
facebookImages={facebookImages}
twitterImages={twitterImages}
canonicalPath={canonicalPath}
schema={{
'@context': 'http://schema.org',
'@type': 'ItemPage',

View file

@ -20,15 +20,21 @@ const noop = () => null;
describe('ListingPage', () => {
it('matches snapshot', () => {
const currentUser = createCurrentUser('user-2');
const listing1 = createListing('listing1', {}, { author: createUser('user-1') });
const id = 'listing1';
const slug = 'listing1-title';
const listing1 = createListing(id, {}, { author: createUser('user-1') });
const getListing = () => listing1;
const props = {
location: { search: '' },
location: {
pathname: `/l/${slug}/${id}`,
search: '',
hash: '',
},
history: {
push: () => console.log('HistoryPush called'),
},
params: { slug: 'listing1-title', id: 'listing1' },
params: { id, slug },
currentUser,
getListing: getListing,
intl: fakeIntl,

View file

@ -3,7 +3,6 @@
exports[`ListingPage matches snapshot 1`] = `
<Page
author="user-1 display name"
canonicalPath="/l/listing1"
contentType="website"
description="listing1 description"
facebookImages={Array []}

32
src/ducks/Routing.duck.js Normal file
View file

@ -0,0 +1,32 @@
// ================ Action types ================ //
export const LOCATION_CHANGED = 'app/Routing/LOCATION_CHANGED';
// ================ Reducer ================ //
const initialState = {
currentLocation: null,
currentCanonicalUrl: null,
};
export default function routingReducer(state = initialState, action = {}) {
const { type, payload } = action;
switch (type) {
case LOCATION_CHANGED:
return {
...state,
currentLocation: payload.location,
currentCanonicalUrl: payload.canonicalUrl,
};
default:
return state;
}
}
// ================ Action creators ================ //
export const locationChanged = (location, canonicalUrl) => ({
type: LOCATION_CHANGED,
payload: { location, canonicalUrl },
});

View file

@ -6,20 +6,22 @@
import { reducer as form } from 'redux-form';
import Auth from './Auth.duck';
import EmailVerification from './EmailVerification.duck';
import FlashNotification from './FlashNotification.duck';
import LocationFilter from './LocationFilter.duck';
import Routing from './Routing.duck';
import UI from './UI.duck';
import marketplaceData from './marketplaceData.duck';
import user from './user.duck';
import EmailVerification from './EmailVerification.duck';
export {
form,
Auth,
EmailVerification,
FlashNotification,
LocationFilter,
Routing,
UI,
form,
marketplaceData,
user,
EmailVerification,
};

View file

@ -24,6 +24,7 @@ import { authInfo } from './ducks/Auth.duck';
import { fetchCurrentUser } from './ducks/user.duck';
import routeConfiguration from './routeConfiguration';
import * as log from './util/log';
import { LoggingAnalyticsHandler, GoogleAnalyticsHandler } from './analytics/handlers';
import './marketplaceIndex.css';
@ -53,6 +54,22 @@ const setupStripe = () => {
window.Stripe.setPublishableKey(config.stripe.publishableKey);
};
const setupAnalyticsHandlers = () => {
let handlers = [];
// Log analytics page views and events in dev mode
if (config.dev) {
handlers.push(new LoggingAnalyticsHandler());
}
// Add Google Analytics handler if tracker ID is found
if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) {
handlers.push(new GoogleAnalyticsHandler(window.ga));
}
return handlers;
};
// If we're in a browser already, render the client application.
if (typeof window !== 'undefined') {
// set up logger with Sentry DSN client key and environment
@ -74,7 +91,8 @@ if (typeof window !== 'undefined') {
},
],
});
const store = configureStore(sdk, initialState);
const analyticsHandlers = setupAnalyticsHandlers();
const store = configureStore(initialState, sdk, analyticsHandlers);
setupStripe();
render(store);

View file

@ -2,13 +2,14 @@
import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import createReducer from './reducers';
import * as analytics from './analytics/analytics';
/**
* Create a new store with the given initial state. Adds Redux
* middleware and enhancers.
*/
export default function configureStore(sdk, initialState = {}) {
const middlewares = [thunk.withExtraArgument(sdk)];
export default function configureStore(initialState = {}, sdk = null, analyticsHandlers = []) {
const middlewares = [thunk.withExtraArgument(sdk), analytics.createMiddleware(analyticsHandlers)];
// Enable Redux Devtools in client side dev mode.
const composeEnhancers =

View file

@ -88,3 +88,32 @@ export const findRouteByRouteName = (nameToFind, routes) => {
}
return route;
};
/**
* Get the canonical URL from the given location
*
* @param {Array<{ route }>} routes - Route configuration as flat array
* @param {Object} location - location object from React Router
*
* @return {String} Canonical URL of the given location
*
*/
export const canonicalRoutePath = (routes, location) => {
const { pathname, search, hash } = location;
const matches = matchPathname(pathname, routes);
const isListingRoute = matches.length === 1 && matches[0].route.name === 'ListingPage';
if (isListingRoute) {
// Remove the dynamic slug from the listing page canonical URL
const parts = pathname.split('/');
if (parts.length !== 4) {
throw new Error('Expecting ListingPage pathname to have 4 parts');
}
const canonicalListingPathname = `/${parts[1]}/${parts[3]}`;
return `${canonicalListingPathname}${search}${hash}`;
}
return `${pathname}${search}${hash}`;
};

View file

@ -4,7 +4,7 @@ import { RoutesProvider } from '../components';
import routeConfiguration from '../routeConfiguration';
import { renderDeep, renderShallow } from './test-helpers';
import * as propTypes from './propTypes';
import { createResourceLocatorString, findRouteByRouteName } from './routes';
import { createResourceLocatorString, findRouteByRouteName, canonicalRoutePath } from './routes';
const { arrayOf } = PropTypes;
@ -68,4 +68,67 @@ describe('util/routes.js', () => {
);
});
});
describe('canonicalRoutePath', () => {
it('handles non-listing route', () => {
const routes = routeConfiguration();
const location = {
pathname: '/',
search: '?some=value',
hash: '#and-some-hash',
};
expect(canonicalRoutePath(routes, location)).toEqual('/?some=value#and-some-hash');
});
it('handles ListingPage', () => {
const routes = routeConfiguration();
const location = {
pathname: '/l/some-slug-here/00000000-0000-0000-0000-000000000000',
search: '',
hash: '',
};
expect(canonicalRoutePath(routes, location)).toEqual(
'/l/00000000-0000-0000-0000-000000000000'
);
});
it('handles ListingBasePage', () => {
const routes = routeConfiguration();
const location = {
pathname: '/l',
search: '',
hash: '',
};
expect(canonicalRoutePath(routes, location)).toEqual('/l');
});
it('handles CheckoutPage', () => {
const routes = routeConfiguration();
const location = {
pathname: '/l/some-slug-here/00000000-0000-0000-0000-000000000000/checkout',
search: '',
hash: '',
};
expect(canonicalRoutePath(routes, location)).toEqual(
'/l/some-slug-here/00000000-0000-0000-0000-000000000000/checkout'
);
});
it('handles NewListingPage', () => {
const routes = routeConfiguration();
const location = {
pathname: '/l/new',
search: '',
hash: '',
};
expect(canonicalRoutePath(routes, location)).toEqual('/l/new');
});
it('handles ListingPageCanonical', () => {
const routes = routeConfiguration();
const location = {
pathname: '/l/00000000-0000-0000-0000-000000000000',
search: '',
hash: '',
};
expect(canonicalRoutePath(routes, location)).toEqual(
'/l/00000000-0000-0000-0000-000000000000'
);
});
});
});

View file

@ -1,7 +1,5 @@
import config from '../config';
export const canonicalURL = path => `${config.canonicalRootURL}${path}`;
const ensureOpenGraphLocale = locale => {
switch (locale) {
case 'en':