From 7dafefbfa729910882f8f1b8153694692f894eac Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Tue, 13 Feb 2024 13:23:03 -0800 Subject: [PATCH] Added PostHog identify calls in admin using hashed email address of user (#19685) refs PA-24 - Added PostHog identify() calls using the user's hashed email address when a user is logged into admin - Added PostHog reset() calls to reset PostHog's distinct_id when a user logs out of admin - These events will only be sent in Admin running on Ghost(Pro), and won't impact self-hosted instances. --- .../app/components/koenig-image-editor.js | 2 +- ghost/admin/app/services/session.js | 12 +++ ghost/admin/app/utils/analytics.js | 79 ++++++++++++++++++- 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/ghost/admin/app/components/koenig-image-editor.js b/ghost/admin/app/components/koenig-image-editor.js index 209d47798c..1ab92083b2 100644 --- a/ghost/admin/app/components/koenig-image-editor.js +++ b/ghost/admin/app/components/koenig-image-editor.js @@ -1,8 +1,8 @@ import Component from '@glimmer/component'; -import trackEvent from '../utils/analytics'; import {action} from '@ember/object'; import {inject} from 'ghost-admin/decorators/inject'; import {inject as service} from '@ember/service'; +import {trackEvent} from '../utils/analytics'; import {tracked} from '@glimmer/tracking'; export default class KoenigImageEditor extends Component { diff --git a/ghost/admin/app/services/session.js b/ghost/admin/app/services/session.js index 53b387717b..360930a73d 100644 --- a/ghost/admin/app/services/session.js +++ b/ghost/admin/app/services/session.js @@ -2,6 +2,7 @@ import ESASessionService from 'ember-simple-auth/services/session'; import RSVP from 'rsvp'; import {configureScope} from '@sentry/ember'; import {getOwner} from '@ember/application'; +import {identifyUser, resetUser} from '../utils/analytics'; import {inject} from 'ghost-admin/decorators/inject'; import {run} from '@ember/runloop'; import {inject as service} from '@ember/service'; @@ -47,6 +48,9 @@ export default class SessionService extends ESASessionService { this.membersUtils.fetch() ]); + // Identify the user to our analytics service upon successful login + await identifyUser(this.user); + // Theme management requires features to be loaded this.themeManagement.fetch().catch(console.error); // eslint-disable-line no-console @@ -100,12 +104,17 @@ export default class SessionService extends ESASessionService { * If failed, it will be handled by the redirect to sign in. */ async requireAuthentication(transition, route) { + if (this.isAuthenticated && this.user) { + identifyUser(this.user); + } + // Only when ember session invalidated if (!this.isAuthenticated) { transition.abort(); if (this.user) { await this.setup(); + identifyUser(this.user); this.notifications.clearAll(); transition.retry(); } @@ -117,6 +126,9 @@ export default class SessionService extends ESASessionService { handleInvalidation() { let transition = this.appLoadTransition; + // Reset the PostHog user when the session is invalidated (e.g. signout, token expiry, etc.) + resetUser(); + if (transition) { transition.send('authorizationFailed'); } else { diff --git a/ghost/admin/app/utils/analytics.js b/ghost/admin/app/utils/analytics.js index ddce5e4ace..a8d5c310e6 100644 --- a/ghost/admin/app/utils/analytics.js +++ b/ghost/admin/app/utils/analytics.js @@ -1,8 +1,85 @@ // Wrapper function for Plausible event -export default function trackEvent(eventName, props = {}) { +/** + * Hashes a user's email address so we can use it as a distinct_id in PostHog without storing the email address itself + * + * + * @param {string} email an email address + * @returns {(string|null)} a sha256 hash of the email address to use as distinct_id in PostHog — null if hashing fails + */ +async function hashEmail(email) { + try { + const digest = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(email.trim().toLowerCase())); + const hashArray = Array.from(new Uint8Array(digest)); + const hash = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + // Double-check that the hash is a valid sha256 hex string before returning it, else return null + return hash.length === 64 ? hash : null; + } catch (e) { + // Modern browsers all support window.crypto, but we need to check for it to avoid errors on really old browsers + // If any errors occur when hashing email, return null + return null; + } +} + +/** + * Sends a tracking event to Plausible, if installed. + * + * By default, Plausible is not installed, in which case this function no-ops. + * + * @param {string} eventName A string name for the event being tracked + * @param {Object} [props={}] An optional object of properties to include with the event + */ +export function trackEvent(eventName, props = {}) { window.plausible = window.plausible || function () { (window.plausible.q = window.plausible.q || []).push(arguments); }; window.plausible(eventName, {props: props}); } + +/** + * Calls posthog.identify() with a hashed email address as the distinct_id + * + * @param {Object} user A user to identify in PostHog + * @returns {void} + */ +export async function identifyUser(user) { + // Return early if window.posthog doesn't exist + if (!window.posthog) { + return; + } + // User the user exists and has an email address, identify them in PostHog + if (user && user.get('email')) { + const email = user.get('email'); + const hashedEmail = await hashEmail(email); + const distinctId = window.posthog.get_distinct_id(); + // Only continue if hashing was successful, and the user hasn't already been identified + if (hashedEmail && hashedEmail !== distinctId) { + const props = {}; + // Add the user's id + if (user.get('id')) { + props.id = user.get('id'); + } + // Add the user's role + if (user.get('role').name) { + props.role = user.get('role').name.toLowerCase(); + } + // Add the user's created_at date + if (user.get('createdAtUTC')) { + props.created_at = user.get('createdAtUTC').toISOString(); + } + window.posthog.identify(hashedEmail, props); + } + } +} + +/** + * Calls posthog.reset() to clear the current user's distinct_id and all associated properties + * To be called when a user logs out + * + * @returns {void} + */ +export function resetUser() { + if (window.posthog) { + window.posthog.reset(); + } +} \ No newline at end of file