From 9de3bed8a7a735fa5a3af2209929efa67054d019 Mon Sep 17 00:00:00 2001 From: Robert Lin Date: Wed, 1 Nov 2023 08:44:17 -0700 Subject: [PATCH] [Backport 5.2] web/tracking: extract user and session tracking from EventLogger (#57884) (#58029) `EventLogger` is currently a very overloaded mechanism that handles all sorts of tracking mechanisms and cookie configurations on top of event logging. This change extracts user tracking (anonymous user ID, cohorts, and device ID) and session tracking (URL stuff) from `EventLogger` into separate, isolated classes. Functionally everything should be the same, but streamlined significantly. Most importantly, this unblocks us so that we can use these mechanisms for introducing a client implementation for the [new telemetry framework](https://docs.sourcegraph.com/dev/background-information/telemetry) while preserving existing behaviour. This only changes `client/web` - it seems this is copy-pasted into several places, we can work on centralizing them all later. Part of https://github.com/sourcegraph/sourcegraph/issues/56920 Enables https://github.com/sourcegraph/sourcegraph/pull/57939 `sg start dotcom`, then: ``` select * from event_logs where source = 'WEB' order by timestamp desc limit 3; ``` (cherry picked from commit f79aa36cc6723ff8bb65425b8a1be8b71e674acb) --- client/web/BUILD.bazel | 4 + client/web/src/auth/SignUpForm.tsx | 9 +- client/web/src/tracking/cookies.ts | 50 ++++ client/web/src/tracking/eventLogger.ts | 272 +------------------- client/web/src/tracking/sessionTracker.ts | 115 +++++++++ client/web/src/tracking/userTracker.test.ts | 21 ++ client/web/src/tracking/userTracker.ts | 92 +++++++ client/web/src/tracking/util.ts | 4 + client/web/src/user/settings/backend.tsx | 20 +- 9 files changed, 310 insertions(+), 277 deletions(-) create mode 100644 client/web/src/tracking/cookies.ts create mode 100644 client/web/src/tracking/sessionTracker.ts create mode 100644 client/web/src/tracking/userTracker.test.ts create mode 100644 client/web/src/tracking/userTracker.ts diff --git a/client/web/BUILD.bazel b/client/web/BUILD.bazel index 87994410ace9f..6e9d40921b1aa 100644 --- a/client/web/BUILD.bazel +++ b/client/web/BUILD.bazel @@ -1698,8 +1698,11 @@ ts_project( "src/tour/data/index.tsx", "src/tour/hooks.ts", "src/tracking/TelemetricLink.tsx", + "src/tracking/cookies.ts", "src/tracking/eventLogger.ts", "src/tracking/services/serverAdminWrapper.tsx", + "src/tracking/sessionTracker.ts", + "src/tracking/userTracker.ts", "src/tracking/util.ts", "src/types/fzy.js/index.d.ts", "src/user/area/UserArea.tsx", @@ -2007,6 +2010,7 @@ ts_project( "src/team/area/testContext.mock.ts", "src/tour/components/Tour/Tour.test.tsx", "src/tour/components/Tour/useTour.test.tsx", + "src/tracking/userTracker.test.ts", "src/tracking/util.test.ts", "src/user/index.test.ts", "src/user/settings/auth/ExternalAccount.test.tsx", diff --git a/client/web/src/auth/SignUpForm.tsx b/client/web/src/auth/SignUpForm.tsx index d321d09a8c09d..18d44c2479a4e 100644 --- a/client/web/src/auth/SignUpForm.tsx +++ b/client/web/src/auth/SignUpForm.tsx @@ -2,7 +2,6 @@ import React, { useCallback, useMemo, useState } from 'react' import { mdiBitbucket, mdiGithub, mdiGitlab } from '@mdi/js' import classNames from 'classnames' -import cookies from 'js-cookie' import { type Observable, of } from 'rxjs' import { fromFetch } from 'rxjs/fetch' import { catchError, switchMap } from 'rxjs/operators' @@ -17,7 +16,7 @@ import { Link, Icon, Label, Text, Button, AnchorLink, LoaderInput, ErrorAlert } import { LoaderButton } from '../components/LoaderButton' import type { AuthProvider, SourcegraphContext } from '../jscontext' -import { ANONYMOUS_USER_ID_KEY, eventLogger, FIRST_SOURCE_URL_KEY, LAST_SOURCE_URL_KEY } from '../tracking/eventLogger' +import { eventLogger } from '../tracking/eventLogger' import { validatePassword, getPasswordRequirements } from '../util/security' import { OrDivider } from './OrDivider' @@ -110,9 +109,9 @@ export const SignUpForm: React.FunctionComponent { setError(asError(error)) setLoading(false) diff --git a/client/web/src/tracking/cookies.ts b/client/web/src/tracking/cookies.ts new file mode 100644 index 0000000000000..2b8ca5072abd8 --- /dev/null +++ b/client/web/src/tracking/cookies.ts @@ -0,0 +1,50 @@ +import cookies, { type CookieAttributes } from 'js-cookie' + +/** + * Cookies is a simple interface over real cookies from 'js-cookie'. + */ +export interface Cookies { + /** + * Read cookie + */ + get(name: string): string | undefined + /** + * Create a cookie + */ + set(name: string, value: string, options?: CookieAttributes): string | undefined +} + +/** + * Alias for 'js-cookie' default implementation, behind the Cookies interface. + */ +export function defaultCookies(): Cookies { + return cookies +} + +export const userCookieSettings: CookieAttributes = { + // 365 days expiry, but renewed on activity. + expires: 365, + // Enforce HTTPS + secure: true, + // We only read the cookie with JS so we don't need to send it cross-site nor on initial page requests. + // However, we do need it on page redirects when users sign up via OAuth, hence using the Lax policy. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite + sameSite: 'Lax', + // Specify the Domain attribute to ensure subdomains (about.sourcegraph.com) can receive this cookie. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#define_where_cookies_are_sent + domain: location.hostname, +} + +export const deviceSessionCookieSettings: CookieAttributes = { + // ~30 minutes expiry, but renewed on activity. + expires: 0.0208, + // Enforce HTTPS + secure: true, + // We only read the cookie with JS so we don't need to send it cross-site nor on initial page requests. + // However, we do need it on page redirects when users sign up via OAuth, hence using the Lax policy. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite + sameSite: 'Lax', + // Specify the Domain attribute to ensure subdomains (about.sourcegraph.com) can receive this cookie. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#define_where_cookies_are_sent + domain: location.hostname, +} diff --git a/client/web/src/tracking/eventLogger.ts b/client/web/src/tracking/eventLogger.ts index 77afc22b9900c..ec1afe8d0e6cd 100644 --- a/client/web/src/tracking/eventLogger.ts +++ b/client/web/src/tracking/eventLogger.ts @@ -1,4 +1,3 @@ -import cookies, { type CookieAttributes } from 'js-cookie' import { EMPTY, fromEvent, merge, type Observable } from 'rxjs' import { catchError, map, publishReplay, refCount, take } from 'rxjs/operators' import * as uuid from 'uuid' @@ -12,14 +11,12 @@ import type { UTMMarker } from '@sourcegraph/shared/src/tracking/utm' import { observeQuerySelector } from '../util/dom' import { serverAdmin } from './services/serverAdminWrapper' -import { getPreviousMonday, redactSensitiveInfoFromAppURL, stripURLParameters } from './util' +import { sessionTracker } from './sessionTracker' +import { userTracker } from './userTracker' +import { stripURLParameters } from './util' -export const ANONYMOUS_USER_ID_KEY = 'sourcegraphAnonymousUid' -export const COHORT_ID_KEY = 'sourcegraphCohortId' export const FIRST_SOURCE_URL_KEY = 'sourcegraphSourceUrl' export const LAST_SOURCE_URL_KEY = 'sourcegraphRecentSourceUrl' -export const DEVICE_ID_KEY = 'sourcegraphDeviceId' -export const DEVICE_SESSION_ID_KEY = 'sourcegraphSessionId' export const ORIGINAL_REFERRER_KEY = 'originalReferrer' export const MKTO_ORIGINAL_REFERRER_KEY = '_mkto_referrer' export const SESSION_REFERRER_KEY = 'sessionReferrer' @@ -73,47 +70,12 @@ const browserExtensionMessageReceived: Observable<{ platform?: string; version?: ) export class EventLogger implements TelemetryService, SharedEventLogger { - private hasStrippedQueryParameters = false + public readonly user = userTracker + public readonly session = sessionTracker - private anonymousUserID = '' - private cohortID?: string - private firstSourceURL?: string - private lastSourceURL?: string - private deviceID = '' - private deviceSessionID?: string + private hasStrippedQueryParameters = false private eventID = 0 private listeners: Set<(eventName: string) => void> = new Set() - private originalReferrer?: string - private sessionReferrer?: string - private sessionFirstURL?: string - - private readonly cookieSettings: CookieAttributes = { - // 365 days expiry, but renewed on activity. - expires: 365, - // Enforce HTTPS - secure: true, - // We only read the cookie with JS so we don't need to send it cross-site nor on initial page requests. - // However, we do need it on page redirects when users sign up via OAuth, hence using the Lax policy. - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite - sameSite: 'Lax', - // Specify the Domain attribute to ensure subdomains (about.sourcegraph.com) can receive this cookie. - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#define_where_cookies_are_sent - domain: location.hostname, - } - - private readonly deviceSessionCookieSettings: CookieAttributes = { - // ~30 minutes expiry, but renewed on activity. - expires: 0.0208, - // Enforce HTTPS - secure: true, - // We only read the cookie with JS so we don't need to send it cross-site nor on initial page requests. - // However, we do need it on page redirects when users sign up via OAuth, hence using the Lax policy. - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite - sameSite: 'Lax', - // Specify the Domain attribute to ensure subdomains (about.sourcegraph.com) can receive this cookie. - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#define_where_cookies_are_sent - domain: location.hostname, - } constructor() { // EventLogger is never teared down @@ -126,8 +88,6 @@ export class EventLogger implements TelemetryService, SharedEventLogger { logger.debug('%cBrowser extension detected, sync completed', 'color: #aaa') } }) - - this.initializeLogParameters() } private logViewEventInternal(eventName: string, eventProperties?: any, logAsActiveUser = true): void { @@ -143,8 +103,6 @@ export class EventLogger implements TelemetryService, SharedEventLogger { } /** - * @deprecated Use logPageView instead - * * Log a pageview. * Page titles should be specific and human-readable in pascal case, e.g. "SearchResults" or "Blob" or "NewOrg" */ @@ -179,10 +137,10 @@ export class EventLogger implements TelemetryService, SharedEventLogger { * Log a user action or event. * Event labels should be specific and follow a ${noun}${verb} structure in pascal case, e.g. "ButtonClicked" or "SignInInitiated" * - * @param eventLabel: the event name. - * @param eventProperties: event properties. These get logged to our database, but do not get + * @param eventLabel the event name. + * @param eventProperties event properties. These get logged to our database, but do not get * sent to our analytics systems. This may contain private info such as repository names or search queries. - * @param publicArgument: event properties that include only public information. Do NOT + * @param publicArgument event properties that include only public information. Do NOT * include any private information, such as full URLs that may contain private repo names or * search queries. The contents of this parameter are sent to our analytics systems. */ @@ -206,133 +164,6 @@ export class EventLogger implements TelemetryService, SharedEventLogger { } } - /** - * Get the anonymous identifier for this user (used to allow site admins - * on a Sourcegraph instance to see a count of unique users on a daily, - * weekly, and monthly basis). - */ - public getAnonymousUserID(): string { - return this.anonymousUserID - } - - /** - * The cohort ID is generated when the anonymous user ID is generated. - * Users that have visited before the introduction of cohort IDs will not have one. - */ - public getCohortID(): string | undefined { - return this.cohortID - } - - public getFirstSourceURL(): string { - const firstSourceURL = this.firstSourceURL || cookies.get(FIRST_SOURCE_URL_KEY) || location.href - - const redactedURL = redactSensitiveInfoFromAppURL(firstSourceURL) - - // Use cookies instead of localStorage so that the ID can be shared with subdomains (about.sourcegraph.com). - // Always set to renew expiry and migrate from localStorage - cookies.set(FIRST_SOURCE_URL_KEY, redactedURL, this.cookieSettings) - - this.firstSourceURL = firstSourceURL - return firstSourceURL - } - - public getLastSourceURL(): string { - // The cookie value gets overwritten each time a user visits a *.sourcegraph.com property. This code - // lives in Google Tag Manager. - const lastSourceURL = this.lastSourceURL || cookies.get(LAST_SOURCE_URL_KEY) || location.href - - const redactedURL = redactSensitiveInfoFromAppURL(lastSourceURL) - - // Use cookies instead of localStorage so that the ID can be shared with subdomains (about.sourcegraph.com). - // Always set to renew expiry and migrate from localStorage - cookies.set(LAST_SOURCE_URL_KEY, redactedURL, this.cookieSettings) - - this.lastSourceURL = lastSourceURL - return lastSourceURL - } - - public getOriginalReferrer(): string { - // Gets the original referrer from the cookie or if it doesn't exist, the mkto_referrer from the URL. - const originalReferrer = - this.originalReferrer || - cookies.get(ORIGINAL_REFERRER_KEY) || - cookies.get(MKTO_ORIGINAL_REFERRER_KEY) || - document.referrer - try { - // 🚨 SECURITY: If the referrer is a valid Sourcegraph.com URL, - // only send the hostname instead of the whole URL to avoid - // leaking private repository names and files into our data. - const url = new URL(originalReferrer) - const regexp = new RegExp('.sourcegraph.com') - if (url.hostname === 'sourcegraph.com' || regexp.test(url.hostname)) { - this.originalReferrer = '' - cookies.set(ORIGINAL_REFERRER_KEY, this.originalReferrer, this.cookieSettings) - return this.originalReferrer - } - cookies.set(ORIGINAL_REFERRER_KEY, originalReferrer, this.cookieSettings) - return originalReferrer - } catch { - this.originalReferrer = '' - cookies.set(ORIGINAL_REFERRER_KEY, this.originalReferrer, this.cookieSettings) - return this.originalReferrer - } - } - - public getSessionReferrer(): string { - // Gets the session referrer from the cookie - const sessionReferrer = this.sessionReferrer || cookies.get(SESSION_REFERRER_KEY) || document.referrer - const regexp = new RegExp('.sourcegraph.com') - try { - // 🚨 SECURITY: If the referrer is a valid Sourcegraph.com URL, - // only send the hostname instead of the whole URL to avoid - // leaking private repository names and files into our data. - const url = new URL(sessionReferrer) - if (url.hostname === 'sourcegraph.com' || regexp.test(url.hostname)) { - this.sessionReferrer = '' - cookies.set(SESSION_REFERRER_KEY, this.sessionReferrer, this.deviceSessionCookieSettings) - return this.sessionReferrer - } - cookies.set(SESSION_REFERRER_KEY, sessionReferrer, this.deviceSessionCookieSettings) - return sessionReferrer - } catch { - this.sessionReferrer = '' - cookies.set(SESSION_REFERRER_KEY, this.sessionReferrer, this.deviceSessionCookieSettings) - return this.sessionReferrer - } - } - - public getSessionFirstURL(): string { - const sessionFirstURL = this.sessionFirstURL || cookies.get(SESSION_FIRST_URL_KEY) || location.href - - const redactedURL = redactSensitiveInfoFromAppURL(sessionFirstURL) - - // Use cookies instead of localStorage so that the ID can be shared with subdomains (about.sourcegraph.com). - // Always set to renew expiry and migrate from localStorage - cookies.set(SESSION_FIRST_URL_KEY, redactedURL, this.deviceSessionCookieSettings) - this.sessionFirstURL = redactedURL - return this.sessionFirstURL - } - - public getDeviceSessionID(): string { - // read from the cookie, otherwise check the global variable - let deviceSessionID = cookies.get(DEVICE_SESSION_ID_KEY) || this.deviceSessionID - if (!deviceSessionID || deviceSessionID === '') { - deviceSessionID = this.getAnonymousUserID() - } - - // Use cookies instead of localStorage so that the ID can be shared with subdomains (about.sourcegraph.com). - // Always set to renew expiry and migrate from localStorage - cookies.set(DEVICE_SESSION_ID_KEY, deviceSessionID, this.deviceSessionCookieSettings) - this.deviceSessionID = deviceSessionID - return deviceSessionID - } - - // Device ID is a require field for Amplitude events. - // https://developers.amplitude.com/docs/http-api-v2 - public getDeviceID(): string { - return this.deviceID - } - // Insert ID is used to deduplicate events in Amplitude. // https://developers.amplitude.com/docs/http-api-v2#optional-keys public getInsertID(): string { @@ -347,22 +178,6 @@ export class EventLogger implements TelemetryService, SharedEventLogger { return this.eventID } - public getReferrer(): string { - const referrer = document.referrer - try { - // 🚨 SECURITY: If the referrer is a valid Sourcegraph.com URL, - // only send the hostname instead of the whole URL to avoid - // leaking private repository names and files into our data. - const url = new URL(referrer) - if (url.hostname === 'sourcegraph.com') { - return 'sourcegraph.com' - } - return referrer - } catch { - return '' - } - } - public getClient(): string { if (window.context?.codyAppMode) { return EventClient.APP_WEB @@ -377,80 +192,13 @@ export class EventLogger implements TelemetryService, SharedEventLogger { // Returns TRUE if successful, FALSE if deviceSessionID cannot be stored private resetSessionCookieExpiration(): boolean { // Function getDeviceSessionID calls cookie.set() to refresh the expiry - const deviceSessionID = this.getDeviceSessionID() + const deviceSessionID = this.user.deviceSessionID if (!deviceSessionID || deviceSessionID === '') { - this.deviceSessionID = deviceSessionID return false } return true } - /** - * Gets the anonymous user ID and cohort ID of the user from cookies. - * If user doesn't have an anonymous user ID yet, a new one is generated, along with - * a cohort ID of the week the user first visited. - * - * If the user already has an anonymous user ID before the introduction of cohort IDs, - * the user will not haved a cohort ID. - * - * If user had an anonymous user ID in localStorage, it will be migrated to cookies. - */ - private initializeLogParameters(): void { - let anonymousUserID = cookies.get(ANONYMOUS_USER_ID_KEY) || localStorage.getItem(ANONYMOUS_USER_ID_KEY) - let cohortID = cookies.get(COHORT_ID_KEY) - this.deviceSessionID = '' - if (!anonymousUserID) { - anonymousUserID = uuid.v4() - cohortID = getPreviousMonday(new Date()) - } - - // Use cookies instead of localStorage so that the ID can be shared with subdomains (about.sourcegraph.com). - // Always set to renew expiry and migrate from localStorage - cookies.set(ANONYMOUS_USER_ID_KEY, anonymousUserID, this.cookieSettings) - localStorage.removeItem(ANONYMOUS_USER_ID_KEY) - - if (cohortID) { - cookies.set(COHORT_ID_KEY, cohortID, this.cookieSettings) - } - - let deviceID = cookies.get(DEVICE_ID_KEY) - if (!deviceID || deviceID === '') { - // If device ID does not exist, use the anonymous user ID value so these are consolidated. - deviceID = anonymousUserID - } - cookies.set(DEVICE_ID_KEY, deviceID, this.cookieSettings) - - let deviceSessionID = cookies.get(DEVICE_SESSION_ID_KEY) || this.deviceSessionID - if (!deviceSessionID || deviceSessionID === '') { - // If device ID does not exist, use the anonymous user ID value so these are consolidated. - deviceSessionID = anonymousUserID - } - cookies.set(DEVICE_SESSION_ID_KEY, deviceSessionID, this.deviceSessionCookieSettings) - - let originalReferrer = cookies.get(ORIGINAL_REFERRER_KEY) - if (!originalReferrer) { - originalReferrer = this.getOriginalReferrer() - } - - let sessionReferrer = cookies.get(SESSION_REFERRER_KEY) - if (!sessionReferrer) { - sessionReferrer = this.getSessionReferrer() - } - - let sessionFirstURL = cookies.get(SESSION_FIRST_URL_KEY) - if (!sessionFirstURL) { - sessionFirstURL = this.getSessionFirstURL() - } - - this.anonymousUserID = anonymousUserID - this.cohortID = cohortID - this.deviceID = deviceID - this.deviceSessionID = deviceSessionID - this.originalReferrer = originalReferrer - this.sessionReferrer = sessionReferrer - this.sessionFirstURL = sessionFirstURL - } - public addEventLogListener(callback: (eventName: string) => void): () => void { this.listeners.add(callback) return () => this.listeners.delete(callback) diff --git a/client/web/src/tracking/sessionTracker.ts b/client/web/src/tracking/sessionTracker.ts new file mode 100644 index 0000000000000..f3eaf1e7f4388 --- /dev/null +++ b/client/web/src/tracking/sessionTracker.ts @@ -0,0 +1,115 @@ +import { type Cookies, defaultCookies, userCookieSettings, deviceSessionCookieSettings } from './cookies' + +const FIRST_SOURCE_URL_KEY = 'sourcegraphSourceUrl' +const LAST_SOURCE_URL_KEY = 'sourcegraphRecentSourceUrl' +const ORIGINAL_REFERRER_KEY = 'originalReferrer' +const MKTO_ORIGINAL_REFERRER_KEY = '_mkto_referrer' +const SESSION_REFERRER_KEY = 'sessionReferrer' +const SESSION_FIRST_URL_KEY = 'sessionFirstUrl' + +/** + * Prefer the global sessionTracker instance. + */ +export class SessionTracker { + /** + * A lot of session-tracking is only done in Sourcegraph.com. + */ + private isSourcegraphDotComMode = window.context?.sourcegraphDotComMode || false + + /** + * We load initial values as the original code would check if we successfully + * loaded a value, and if we didn't, try to load again - see getters on this + * class. + */ + private originalReferrer: string + private sessionReferrer: string + private sessionFirstURL: string + private firstSourceURL: string + private lastSourceURL: string + + constructor(private cookies: Cookies = defaultCookies()) { + this.originalReferrer = this.getOriginalReferrer() + this.sessionReferrer = this.getSessionReferrer() + this.sessionFirstURL = this.getSessionFirstURL() + this.firstSourceURL = this.getFirstSourceURL() + this.lastSourceURL = this.getLastSourceURL() + } + + public getOriginalReferrer(): string { + if (!this.isSourcegraphDotComMode) { + return '' + } + /** + * Gets the original referrer from the cookie or, if it doesn't exist, the + * mkto_referrer from the URL. + */ + this.originalReferrer = + this.originalReferrer || + this.cookies.get(ORIGINAL_REFERRER_KEY) || + this.cookies.get(MKTO_ORIGINAL_REFERRER_KEY) || + document.referrer + + this.cookies.set(ORIGINAL_REFERRER_KEY, this.originalReferrer, userCookieSettings) + + return this.originalReferrer + } + + public getSessionReferrer(): string { + // Gets the session referrer from the cookie + if (!this.isSourcegraphDotComMode) { + return '' + } + this.sessionReferrer = this.sessionReferrer || this.cookies.get(SESSION_REFERRER_KEY) || document.referrer + + this.cookies.set(SESSION_REFERRER_KEY, this.sessionReferrer, deviceSessionCookieSettings) + return this.sessionReferrer + } + + public getSessionFirstURL(): string { + if (!this.isSourcegraphDotComMode) { + return '' + } + this.sessionFirstURL = this.sessionFirstURL || this.cookies.get(SESSION_FIRST_URL_KEY) || location.href + + this.cookies.set(SESSION_FIRST_URL_KEY, this.sessionFirstURL, deviceSessionCookieSettings) + return this.sessionFirstURL + } + + public getFirstSourceURL(): string { + if (!this.isSourcegraphDotComMode) { + return '' + } + this.firstSourceURL = this.firstSourceURL || this.cookies.get(FIRST_SOURCE_URL_KEY) || location.href + + this.cookies.set(FIRST_SOURCE_URL_KEY, this.firstSourceURL, userCookieSettings) + return this.firstSourceURL + } + + public getLastSourceURL(): string { + if (!this.isSourcegraphDotComMode) { + return '' + } + + /** + * The cookie value gets overwritten each time a user visits a *.sourcegraph.com property. + * This code lives in Google Tag Manager. + */ + this.lastSourceURL = this.lastSourceURL || this.cookies.get(LAST_SOURCE_URL_KEY) || location.href + + this.cookies.set(LAST_SOURCE_URL_KEY, this.lastSourceURL, userCookieSettings) + + return this.lastSourceURL + } + + public getReferrer(): string { + if (this.isSourcegraphDotComMode) { + return document.referrer + } + return '' + } +} + +/** + * Configures and loads cookie properties for session tracking purposes. + */ +export const sessionTracker = new SessionTracker() diff --git a/client/web/src/tracking/userTracker.test.ts b/client/web/src/tracking/userTracker.test.ts new file mode 100644 index 0000000000000..dd4de50ed87e1 --- /dev/null +++ b/client/web/src/tracking/userTracker.test.ts @@ -0,0 +1,21 @@ +import { UserTracker } from './userTracker' + +describe('UserTracker', () => { + it('initializes values without existing cookies', () => { + let setCalls = 0 + const tracker = new UserTracker({ + get(): string | undefined { + return undefined + }, + set(): string | undefined { + setCalls += 1 + return undefined + }, + }) + expect(tracker.anonymousUserID).toBeTruthy() + expect(tracker.cohortID).toBeTruthy() + expect(tracker.deviceID).toBeTruthy() + expect(tracker.deviceSessionID).toBeTruthy() + expect(setCalls).toEqual(4) + }) +}) diff --git a/client/web/src/tracking/userTracker.ts b/client/web/src/tracking/userTracker.ts new file mode 100644 index 0000000000000..99a45b8f177da --- /dev/null +++ b/client/web/src/tracking/userTracker.ts @@ -0,0 +1,92 @@ +import * as uuid from 'uuid' + +import { type Cookies, defaultCookies, userCookieSettings, deviceSessionCookieSettings } from './cookies' +import { getPreviousMonday } from './util' + +const ANONYMOUS_USER_ID_KEY = 'sourcegraphAnonymousUid' +const COHORT_ID_KEY = 'sourcegraphCohortId' +const DEVICE_ID_KEY = 'sourcegraphDeviceId' +const DEVICE_SESSION_ID_KEY = 'sourcegraphSessionId' + +/** + * Configures and loads cookie properties for user tracking purposes. + * + * All values are configured and initialized once on the constructor, as values + * are unlikely to change. + * + * Prefer the global userTracker instance. + */ +export class UserTracker { + /** + * The anonymous identifier for this user (used to allow site admins + * on a Sourcegraph instance to see a count of unique users on a daily, + * weekly, and monthly basis). + */ + public readonly anonymousUserID: string + /** + * The cohort ID is generated when the anonymous user ID is generated. + * Users that have visited before the introduction of cohort IDs will not have one. + */ + public readonly cohortID: string | undefined + /** + * Device ID is a require field for Amplitude events: https://developers.amplitude.com/docs/http-api-v2 + */ + public readonly deviceID: string + /** + * Device session ID seems to be the same thing as anonymousUserID for the + * most part. + */ + public readonly deviceSessionID: string + + constructor(cookies: Cookies = defaultCookies()) { + /** + * Gets the anonymous user ID and cohort ID of the user from cookies. + * If user doesn't have an anonymous user ID yet, a new one is generated, along with + * a cohort ID of the week the user first visited. + * + * If the user already has an anonymous user ID before the introduction of cohort IDs, + * the user will not haved a cohort ID. + * + * If user had an anonymous user ID in localStorage, it will be migrated to cookies. + */ + let anonymousUserID = cookies.get(ANONYMOUS_USER_ID_KEY) || localStorage.getItem(ANONYMOUS_USER_ID_KEY) + let cohortID = cookies.get(COHORT_ID_KEY) + if (!anonymousUserID) { + anonymousUserID = uuid.v4() + cohortID = getPreviousMonday(new Date()) + } + + // Use cookies instead of localStorage so that the ID can be shared with subdomains (about.sourcegraph.com). + // Always set to renew expiry and migrate from localStorage + cookies.set(ANONYMOUS_USER_ID_KEY, anonymousUserID, userCookieSettings) + localStorage.removeItem(ANONYMOUS_USER_ID_KEY) + + if (cohortID) { + cookies.set(COHORT_ID_KEY, cohortID, userCookieSettings) + } + + let deviceID = cookies.get(DEVICE_ID_KEY) + if (!deviceID || deviceID === '') { + // If device ID does not exist, use the anonymous user ID value so these are consolidated. + deviceID = anonymousUserID + } + cookies.set(DEVICE_ID_KEY, deviceID, userCookieSettings) + + let deviceSessionID = cookies.get(DEVICE_SESSION_ID_KEY) + if (!deviceSessionID || deviceSessionID === '') { + // If device ID does not exist, use the anonymous user ID value so these are consolidated. + deviceSessionID = anonymousUserID + } + cookies.set(DEVICE_SESSION_ID_KEY, deviceSessionID, deviceSessionCookieSettings) + + this.anonymousUserID = anonymousUserID + this.cohortID = cohortID + this.deviceID = deviceID + this.deviceSessionID = deviceSessionID + } +} + +/** + * Configures and loads cookie properties for user tracking purposes. + */ +export const userTracker = new UserTracker() diff --git a/client/web/src/tracking/util.ts b/client/web/src/tracking/util.ts index fcdeecf86cd5f..8810b08f433b7 100644 --- a/client/web/src/tracking/util.ts +++ b/client/web/src/tracking/util.ts @@ -29,6 +29,10 @@ export function stripURLParameters(url: string, parametersToRemove: string[] = [ * @param url the original, full URL */ export function redactSensitiveInfoFromAppURL(url: string): string { + if (!url) { + return url // nothing to redact + } + const sourceURL = new URL(url) if (sourceURL.hostname !== 'sourcegraph.com') { diff --git a/client/web/src/user/settings/backend.tsx b/client/web/src/user/settings/backend.tsx index 395739a222a88..248952e319c64 100644 --- a/client/web/src/user/settings/backend.tsx +++ b/client/web/src/user/settings/backend.tsx @@ -182,20 +182,20 @@ export function logEventSynchronously( function createEvent(event: string, eventProperties?: unknown, publicArgument?: unknown): Event { return { event, - userCookieID: eventLogger.getAnonymousUserID(), - cohortID: eventLogger.getCohortID() || null, - firstSourceURL: eventLogger.getFirstSourceURL(), - lastSourceURL: eventLogger.getLastSourceURL(), - referrer: eventLogger.getReferrer(), - originalReferrer: eventLogger.getOriginalReferrer(), - sessionReferrer: eventLogger.getSessionReferrer(), - sessionFirstURL: eventLogger.getSessionFirstURL(), - deviceSessionID: eventLogger.getDeviceSessionID(), + userCookieID: eventLogger.user.anonymousUserID, + cohortID: eventLogger.user.cohortID || null, + firstSourceURL: eventLogger.session.getFirstSourceURL(), + lastSourceURL: eventLogger.session.getLastSourceURL(), + referrer: eventLogger.session.getReferrer(), + originalReferrer: eventLogger.session.getOriginalReferrer(), + sessionReferrer: eventLogger.session.getSessionReferrer(), + sessionFirstURL: eventLogger.session.getSessionFirstURL(), + deviceSessionID: eventLogger.user.deviceSessionID, url: window.location.href, source: EventSource.WEB, argument: eventProperties ? JSON.stringify(eventProperties) : null, publicArgument: publicArgument ? JSON.stringify(publicArgument) : null, - deviceID: eventLogger.getDeviceID(), + deviceID: eventLogger.user.deviceID, eventID: eventLogger.getEventID(), insertID: eventLogger.getInsertID(), client: eventLogger.getClient(),