From 5d54b7e80c564bd7d2ceb4a932784c4cfa500f25 Mon Sep 17 00:00:00 2001 From: Filip Stoyanov Date: Mon, 13 Nov 2023 12:29:14 +0000 Subject: [PATCH 01/15] filip(feat): add posthog integration --- gatsby-config.js | 14 ++++++++++++++ package.json | 1 + yarn.lock | 8 ++++++++ 3 files changed, 23 insertions(+) diff --git a/gatsby-config.js b/gatsby-config.js index 1056c7f9..a75e7354 100755 --- a/gatsby-config.js +++ b/gatsby-config.js @@ -49,6 +49,20 @@ const plugins = [ extensions: ['.mdx', '.md'], }, }, + { + resolve: `gatsby-plugin-posthog`, + options: { + // Specify the API key for your PostHog Project (required) + apiKey: 'phc_GB82t5tMT4EVKUpOCPTOuhezu3trmJUCGtSnFHB3fK3', + // Specify the app host if self-hosting (optional, default: https://app.posthog.com) + apiHost: 'https://eu.posthog.com', + projectId: 11673, + // Puts tracking script in the head instead of the body (optional, default: true) + head: true, + // Enable posthog analytics tracking during development (optional, default: false) + isEnabledDevMode: true, + }, + }, // { // resolve: 'gatsby-transformer-remark', // options: { diff --git a/package.json b/package.json index 9e378da0..b39e66fc 100755 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "gatsby-plugin-manifest": "^2.4.28", "gatsby-plugin-mdx": "^1.0.61", "gatsby-plugin-offline": "^3.0.29", + "gatsby-plugin-posthog": "^1.0.1", "gatsby-plugin-react-helmet": "^3.1.18", "gatsby-plugin-remove-serviceworker": "^1.0.0", "gatsby-plugin-sharp": "^2.6.35", diff --git a/yarn.lock b/yarn.lock index 9d5058b0..210748d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1684,6 +1684,7 @@ integrity sha512-OYMNS7UBD5tL88NRy5nHsJYaQ++IbvQP3lKwoimaJnhTTybfcJL4iEhFukQer50Ot75ZSRfCJEmdXG+kGe1+FQ== dependencies: prop-types "^15.6.2" + react-ga "^2.7.0" "@input-output-hk/front-end-site-components@^1.2.10": version "1.4.0" @@ -8450,6 +8451,13 @@ gatsby-plugin-page-creator@^2.10.2: globby "^11.0.2" lodash "^4.17.20" +gatsby-plugin-posthog@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/gatsby-plugin-posthog/-/gatsby-plugin-posthog-1.0.1.tgz#e455720a66d235ed8d7f508e9e575f95e20f189b" + integrity sha512-RIyDTj/XcMvmV6Vs6HJa8zQFGcQr52LcS4iD76g5WfFB705hFOAPchIISFoFMQd3qN1n3m4jUinhA/uuhN43Qg== + dependencies: + "@babel/runtime" "^7.8.7" + gatsby-plugin-react-helmet@^3.1.18: version "3.10.0" resolved "https://registry.yarnpkg.com/gatsby-plugin-react-helmet/-/gatsby-plugin-react-helmet-3.10.0.tgz#421bbee87157c351d19031d62145c2ab6c00ef94" From 9443b516ba25b299ff12708354937103db50d36a Mon Sep 17 00:00:00 2001 From: Filip Stoyanov Date: Mon, 13 Nov 2023 12:44:09 +0000 Subject: [PATCH 02/15] filip(chore): abstract posthog variables --- gatsby-config.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gatsby-config.js b/gatsby-config.js index a75e7354..b51a314a 100755 --- a/gatsby-config.js +++ b/gatsby-config.js @@ -53,10 +53,10 @@ const plugins = [ resolve: `gatsby-plugin-posthog`, options: { // Specify the API key for your PostHog Project (required) - apiKey: 'phc_GB82t5tMT4EVKUpOCPTOuhezu3trmJUCGtSnFHB3fK3', + apiKey: process.env.POSTHOG_API_KEY, // Specify the app host if self-hosting (optional, default: https://app.posthog.com) - apiHost: 'https://eu.posthog.com', - projectId: 11673, + apiHost: process.env.POSTHOG_API_HOST, + projectId: process.env.POSTHOG_PROJECT_ID, // Puts tracking script in the head instead of the body (optional, default: true) head: true, // Enable posthog analytics tracking during development (optional, default: false) From 113e67957570cf6ae76e1e7091dfc8f400cb9adc Mon Sep 17 00:00:00 2001 From: Filip Stoyanov Date: Mon, 13 Nov 2023 12:50:08 +0000 Subject: [PATCH 03/15] filip(wip): verify env vars --- src/components/Footer/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Footer/index.js b/src/components/Footer/index.js index b2509ac6..c1f2d8ee 100644 --- a/src/components/Footer/index.js +++ b/src/components/Footer/index.js @@ -195,6 +195,7 @@ const Footer = ({ theme }) => { const content = data.content let logoURL = 'https://ucarecdn.com/75b74f03-ff04-47ba-821c-5e477d3d46d4/' + console.log(process.env.POSTHOG_API_KEY) return ( From b018b58112708c1e75427faa4b9ddf40e2c29807 Mon Sep 17 00:00:00 2001 From: Filip Stoyanov Date: Mon, 13 Nov 2023 12:54:00 +0000 Subject: [PATCH 04/15] filip(wip): verify env vars --- src/components/Footer/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Footer/index.js b/src/components/Footer/index.js index c1f2d8ee..fffbc765 100644 --- a/src/components/Footer/index.js +++ b/src/components/Footer/index.js @@ -195,7 +195,7 @@ const Footer = ({ theme }) => { const content = data.content let logoURL = 'https://ucarecdn.com/75b74f03-ff04-47ba-821c-5e477d3d46d4/' - console.log(process.env.POSTHOG_API_KEY) + console.log('env var + ' + process.env.POSTHOG_API_KEY) return ( From b79e59eef68c8bd239fd8d8169108cad9018241e Mon Sep 17 00:00:00 2001 From: Filip Stoyanov Date: Mon, 13 Nov 2023 13:03:03 +0000 Subject: [PATCH 05/15] filip(wip): verify env vars --- src/components/Footer/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Footer/index.js b/src/components/Footer/index.js index fffbc765..665a0b8b 100644 --- a/src/components/Footer/index.js +++ b/src/components/Footer/index.js @@ -195,7 +195,7 @@ const Footer = ({ theme }) => { const content = data.content let logoURL = 'https://ucarecdn.com/75b74f03-ff04-47ba-821c-5e477d3d46d4/' - console.log('env var + ' + process.env.POSTHOG_API_KEY) + console.log('env var - ' + process.env.POSTHOG_API_KEY) return ( From 4a69a578bd493966c7a030d3fd281af05e7d352f Mon Sep 17 00:00:00 2001 From: Filip Stoyanov Date: Mon, 13 Nov 2023 13:11:08 +0000 Subject: [PATCH 06/15] filip(wip): verify env vars --- src/components/Footer/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Footer/index.js b/src/components/Footer/index.js index 665a0b8b..da1e9a92 100644 --- a/src/components/Footer/index.js +++ b/src/components/Footer/index.js @@ -195,7 +195,7 @@ const Footer = ({ theme }) => { const content = data.content let logoURL = 'https://ucarecdn.com/75b74f03-ff04-47ba-821c-5e477d3d46d4/' - console.log('env var - ' + process.env.POSTHOG_API_KEY) + console.log('env var ' + process.env.POSTHOG_API_KEY) return ( From ba1bc16ef5b72360a647ac1ff0c807bb873c7818 Mon Sep 17 00:00:00 2001 From: Filip Stoyanov Date: Mon, 13 Nov 2023 13:15:04 +0000 Subject: [PATCH 07/15] filip(chore): clean up logs --- src/components/Footer/index.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/Footer/index.js b/src/components/Footer/index.js index da1e9a92..353a1d56 100644 --- a/src/components/Footer/index.js +++ b/src/components/Footer/index.js @@ -194,8 +194,6 @@ const LinksColumn = styled.div` const Footer = ({ theme }) => { const content = data.content let logoURL = 'https://ucarecdn.com/75b74f03-ff04-47ba-821c-5e477d3d46d4/' - - console.log('env var ' + process.env.POSTHOG_API_KEY) return ( From 98c9751826efa66ef94c67c287ba1610ef643f0e Mon Sep 17 00:00:00 2001 From: Filip Stoyanov Date: Wed, 15 Nov 2023 14:04:25 +0000 Subject: [PATCH 08/15] filip(fix): change implementation to ensure analytics are only collected with user consent --- gatsby-config.js | 14 --- package.json | 3 +- src/analytics/AnalyticsContext.js | 160 ++++++++++++++++++++++++++++++ src/analytics/TrackRoute.js | 15 +++ src/analytics/osano.js | 17 ++++ src/analytics/useHasConsent.js | 45 +++++++++ src/components/layout.js | 65 ++++++------ yarn.lock | 29 ++++-- 8 files changed, 295 insertions(+), 53 deletions(-) create mode 100644 src/analytics/AnalyticsContext.js create mode 100644 src/analytics/TrackRoute.js create mode 100644 src/analytics/osano.js create mode 100644 src/analytics/useHasConsent.js diff --git a/gatsby-config.js b/gatsby-config.js index b51a314a..1056c7f9 100755 --- a/gatsby-config.js +++ b/gatsby-config.js @@ -49,20 +49,6 @@ const plugins = [ extensions: ['.mdx', '.md'], }, }, - { - resolve: `gatsby-plugin-posthog`, - options: { - // Specify the API key for your PostHog Project (required) - apiKey: process.env.POSTHOG_API_KEY, - // Specify the app host if self-hosting (optional, default: https://app.posthog.com) - apiHost: process.env.POSTHOG_API_HOST, - projectId: process.env.POSTHOG_PROJECT_ID, - // Puts tracking script in the head instead of the body (optional, default: true) - head: true, - // Enable posthog analytics tracking during development (optional, default: false) - isEnabledDevMode: true, - }, - }, // { // resolve: 'gatsby-transformer-remark', // options: { diff --git a/package.json b/package.json index b39e66fc..344cf089 100755 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "gatsby-plugin-manifest": "^2.4.28", "gatsby-plugin-mdx": "^1.0.61", "gatsby-plugin-offline": "^3.0.29", - "gatsby-plugin-posthog": "^1.0.1", + "dayjs": "^1.11.9", + "posthog-js": "^1.76.0", "gatsby-plugin-react-helmet": "^3.1.18", "gatsby-plugin-remove-serviceworker": "^1.0.0", "gatsby-plugin-sharp": "^2.6.35", diff --git a/src/analytics/AnalyticsContext.js b/src/analytics/AnalyticsContext.js new file mode 100644 index 00000000..18406d94 --- /dev/null +++ b/src/analytics/AnalyticsContext.js @@ -0,0 +1,160 @@ +import React, { createContext, useCallback, useEffect, useState } from 'react' +import dayjs from 'dayjs' +import { posthog } from 'posthog-js' +import { PostHogProvider } from 'posthog-js/react' +import TrackRoute from './TrackRoute' +import useHasConsent, { ConsentType } from './useHasConsent' + +/** + * @file This file exports an AnalyticsContext and an AnalyticsProvider component. + * The AnalyticsContext is a context object that provides a function to capture analytics events. + * The AnalyticsProvider is a component that provides the PostHog client and wraps its children with the AnalyticsContext. + * @module AnalyticsContext + */ + +/** + * A context object that provides a function to capture analytics events. + * @typedef {Object} AnalyticsContext + * @property {Function} capture - A function that captures an analytics event. + * @param {string} eventName - The name of the event to capture. + * @param {Object} [eventProps] - An optional object containing additional properties to include in the event. + */ + +/** + * A component that provides the PostHog client and wraps its children with the AnalyticsContext. + * @typedef {Object} AnalyticsProvider + * @property {Object} children - The child components to wrap with the AnalyticsContext. + */ + +/** + * The base event properties to include in all analytics events. + * @typedef {Function} BaseEventProps + * @returns {Object} An object containing the base event properties. + */ + +/** + * A hook that returns the base event properties to include in all analytics events. + * @typedef {Function} UseBaseEventProps + * @returns {BaseEventProps} A function that returns an object containing the base event properties. + */ + +/** + * A hook that returns a function to capture analytics events. + * @typedef {Function} UseAnalyticsCapture + * @returns {Function} A function that captures an analytics event. + * @param {string} eventName - The name of the event to capture. + * @param {Object} [eventProps] - An optional object containing additional properties to include in the event. + */ + +/** + * A hook that returns the PostHog client. + * @typedef {Function} UsePostHogClient + * @returns {Object} The PostHog client. + */ + +/** + * A hook that returns whether the user has accepted analytics consent. + * @typedef {Function} UseHasAnalyticsConsent + * @returns {boolean} Whether the user has accepted analytics consent. + */ + +/** + * A hook that returns the PostHog client and a function to capture analytics events. + * @typedef {Function} UseAnalytics + * @returns {Array} An array containing the PostHog client and a function to capture analytics events. + * @param {string} eventName - The name of the event to capture. + * @param {Object} [eventProps] - An optional object containing additional properties to include in the event. + */ + +/** + * The props for the AnalyticsProvider component. + * @typedef {Object} AnalyticsProviderProps + * @property {Object} children - The child components to wrap with the AnalyticsContext. + */ + +/** + * The props for the AnalyticsContextProvider component. + * @typedef {Object} AnalyticsContextProviderProps + * @property {Function} capture - A function that captures an analytics event. + * @param {string} eventName - The name of the event to capture. + * @param {Object} [eventProps] - An optional object containing additional properties to include in the event. + * @property {Object} children - The child components to wrap with the AnalyticsContext. + */ +export const AnalyticsContext = createContext(() => {}) + +export function AnalyticsProvider({ children }) { + const [client, setClient] = useState() + + const analyticsAccepted = useHasConsent(ConsentType.ANALYTICS) + + const baseEventProps = useCallback( + () => ({ + sent_at_local: dayjs().format(), + posthog_project_id: process.env.POSTHOG_PROJECT_ID, + }), + [], + ) + + const capture = useCallback( + (eventName, eventProperties = {}) => { + if (client) { + client.capture(eventName, { + ...baseEventProps(), + ...eventProperties, + }) + } + }, + [client, baseEventProps], + ) + + useEffect(() => { + const posthogApiKey = process.env.POSTHOG_API_KEY + + const posthogApiHost = process.env.POSTHOG_API_HOST + + const turnOn = + analyticsAccepted === true && + typeof posthogApiKey === 'string' && + posthogApiKey && + typeof posthogApiHost === 'string' && + posthogApiHost + + setClient(oldClient => { + if (turnOn) { + const client = + oldClient ?? + posthog.init(posthogApiKey ?? '', { + api_host: posthogApiHost, + capture_pageleave: false, + capture_pageview: false, + }) + + // clear localStorage state that might have been set by previous clients + client.clear_opt_in_out_capturing() + + // we got consent, start capturing + client.opt_in_capturing({ + capture_properties: baseEventProps(), + }) + // calling a private function as a fix for bug https://github.com/PostHog/posthog-js/issues/336 + client._start_queue_if_opted_in() + + return client + } else { + if (oldClient) { + oldClient.opt_out_capturing() + } + return undefined + } + }) + }, [analyticsAccepted, baseEventProps]) + + return ( + + + + {children} + + + ) +} diff --git a/src/analytics/TrackRoute.js b/src/analytics/TrackRoute.js new file mode 100644 index 00000000..5008a9c1 --- /dev/null +++ b/src/analytics/TrackRoute.js @@ -0,0 +1,15 @@ +import { useLocation } from '@reach/router' +import { useContext, useEffect } from 'react' +import { AnalyticsContext } from './AnalyticsContext' + +export default function TrackRoute() { + const location = useLocation() + + const capture = useContext(AnalyticsContext) + + useEffect(() => { + capture('$pageview') + }, [capture, location.pathname, location.search]) + + return null +} diff --git a/src/analytics/osano.js b/src/analytics/osano.js new file mode 100644 index 00000000..265f3ca9 --- /dev/null +++ b/src/analytics/osano.js @@ -0,0 +1,17 @@ +export const OsanoConsentType = { + ESSENTIAL: 'ESSENTIAL', + STORAGE: 'STORAGE', + MARKETING: 'MARKETING', + PERSONALIZATION: 'PERSONALIZATION', + ANALYTICS: 'ANALYTICS', + OPT_OUT: 'OPT_OUT', +} + +export const OsanoConsentDecision = { + ACCEPT: 'ACCEPT', + DENY: 'DENY', +} + +export const OsanoEvent = { + CONSENT_SAVED: 'osano-cm-consent-saved', +} diff --git a/src/analytics/useHasConsent.js b/src/analytics/useHasConsent.js new file mode 100644 index 00000000..9eb99a2d --- /dev/null +++ b/src/analytics/useHasConsent.js @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react' +import { OsanoConsentDecision, OsanoConsentType, OsanoEvent } from './osano' + +export { OsanoConsentType as ConsentType } + +export default function useHasConsent(type) { + let initialConsent + + try { + initialConsent = + typeof window && typeof window.Osano !== 'undefined' + ? window.Osano.cm.getConsent()[type] === OsanoConsentDecision.ACCEPT + : undefined + } catch (err) { + console.log(err) + } + + const [state, setState] = useState(initialConsent) + + useEffect(() => { + const cm = + typeof window && typeof window.Osano !== 'undefined' + ? window.Osano.cm + : undefined + + if (!cm) { + return + } + + setState(cm.getConsent()[type] === OsanoConsentDecision.ACCEPT) + + const handler = changed => { + if (type in changed) { + setState(changed[type] === OsanoConsentDecision.ACCEPT) + } + } + + cm.addEventListener(OsanoEvent.CONSENT_SAVED, handler) + return () => { + cm.removeEventListener(OsanoEvent.CONSENT_SAVED, handler) + } + }, [type]) + + return state +} diff --git a/src/components/layout.js b/src/components/layout.js index 1fa0c282..abcb5506 100644 --- a/src/components/layout.js +++ b/src/components/layout.js @@ -7,6 +7,7 @@ import mdxComponents from './mdxComponents' import Sidebar from './sidebar' import RightSidebar from './rightSidebar' import config from '../../config.js' +import { AnalyticsProvider } from '../analytics/AnalyticsContext.js' const SiteWrap = styled.div` background-color: ${({ theme }) => theme.colors.background}; @@ -21,7 +22,7 @@ const Wrapper = styled('div')` .sideBarUL li a, .sectionHeading { - cursor:pointer; + cursor: pointer; color: ${({ theme }) => theme.colors.text}; } @@ -76,38 +77,40 @@ const RightSideBarWidth = styled('div')` ` const Layout = ({ children, location, useFwTemplate }, theme) => ( - - - - - {useFwTemplate ? ( - - {children} - - ) : ( - <> - - - - {config.sidebar.title ? ( -
- ) : null} - + + + + + + {useFwTemplate ? ( + {children} - - {location && } - - - )} - -