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(),