import * as qs from 'query-string'; import ConfigStore from 'sentry/stores/configStore'; import type {Hooks} from 'sentry/types/hooks'; import type {Organization} from 'sentry/types/organization'; import type {User} from 'sentry/types/user'; import getDaysSinceDate from 'sentry/utils/getDaysSinceDate'; import {uniqueId} from 'sentry/utils/guid'; import localStorage from 'sentry/utils/localStorage'; import sessionStorage from 'sentry/utils/sessionStorage'; import type {Subscription} from 'getsentry/types'; import trackAmplitudeEvent from './trackAmplitudeEvent'; import trackMarketingEvent from './trackMarketingEvent'; import trackPendoEvent from './trackPendoEvent'; import trackReloadEvent from './trackReloadEvent'; /** * Fields that are listed here which are passed to trackAnalyticsEvent's data * will automatically be coerced into integers. */ const COERCE_FIELDS = ['project_id', 'organization_id', 'user_id', 'org_id']; function coerceNumber(value: string | undefined | null) { const originalValue = value; if (value === undefined) { return undefined; } if (value === null) { return null; } // Attempt to coerce the value to a number const numberValue = Number(value); // Still unable to coerce to a number? that's a failure if (isNaN(numberValue)) { throw new Error(`Unable to coerce value to number: '${originalValue}'`); } return numberValue; } const MARKETING_EVENT_NAMES = new Set([ 'Growth: Onboarding Load Choose Platform Page', 'Growth: Onboarding Choose Platform', 'Growth: Onboarding Start Onboarding', 'Growth: Onboarding View Sample Event', 'Growth: Onboarding Click Set Up Your Project', 'Growth: Onboarding Take to Error', 'Growth: Onboarding Clicked Need Help', ]); const ANALYTICS_SESSION = 'ANALYTICS_SESSION'; const startAnalyticsSession = () => { const sessionId = uniqueId(); sessionStorage.setItem(ANALYTICS_SESSION, sessionId); return sessionId; }; const getAnalyticsSessionId = () => sessionStorage.getItem(ANALYTICS_SESSION); const hasAnalyticsDebug = () => localStorage.getItem('DEBUG_ANALYTICS_GETSENTRY') === '1'; const getCustomReferrer = () => { try { // pull the referrer from the query parameter of the page const {referrer} = qs.parse(window.location.search) || {}; if (referrer && typeof referrer === 'string') { return referrer; } } catch { // ignore if this fails to parse // this can happen if we have an invalid query string // e.g. unencoded "%" } return undefined; }; const getOrganizationId = ( organization: Organization | string | null ): number | undefined | null => { // this should never happen but there are components that use withOrganization // that might end up with an undefined org if used incorrectly if (organization === undefined) { // eslint-disable-next-line no-console console.warn('Unexpected undefined organization'); return undefined; } if (typeof organization === 'string') { const orgId = Number(organization); if (isNaN(orgId)) { // eslint-disable-next-line no-console console.warn(`Invalid organization ID: ${organization}`); return undefined; } return orgId; } // if organization is null, organization_id needs to be null return organization === null ? null : Number(organization.id); }; const getOrganizationAge = ( organization: Organization | string | null ): number | null => { if (typeof organization === 'string') { return null; } if (typeof organization?.dateCreated === 'string') { const orgAge = getDaysSinceDate(organization?.dateCreated); return orgAge; } return null; }; const getUserAge = (user: User): number => { return getDaysSinceDate(user.dateJoined); }; type RawTrackEventHook = Hooks['analytics:raw-track-event']; type Params = Parameters[0] & { subscription?: Subscription; }; type Options = Parameters[1]; /** * Returns true if the organization input has all the properties of a full organization */ function isFullOrganization( organization: Params['organization'] ): organization is Organization { return !!organization && typeof organization !== 'string'; } export default function rawTrackAnalyticsEvent( {eventKey, eventName, organization, subscription, ...data}: Params, options?: Options ) { try { // apply custom function map parameters const {mapValuesFn} = options || {}; if (mapValuesFn) { data = mapValuesFn(data); } const time = options?.time; const organization_id = getOrganizationId(organization); // Coerce number fields Object.keys(data) .filter(field => COERCE_FIELDS.includes(field)) .forEach(field => (data[field] = coerceNumber(data[field]))); let sessionId = options?.startSession ? startAnalyticsSession() : getAnalyticsSessionId(); // we should always have a session id but if we don't, we should generate one if (!sessionId) { sessionId = startAnalyticsSession(); } data.analytics_session_id = sessionId; // add custom referrer if available const customReferrer = getCustomReferrer(); if (customReferrer) { data.custom_referrer = customReferrer; } // add in previous referrer if different than custom referrer const prevReferrer = sessionStorage.getItem('previous_referrer'); if (prevReferrer && prevReferrer !== customReferrer) { data.previous_referrer = prevReferrer; } // pass in properties if we have the full organization if (isFullOrganization(organization)) { data.role = organization.orgRole; } // add in plan information if (subscription) { data.plan = data.plan || subscription.plan; if (data.can_trial === undefined) { data.can_trial = subscription.canTrial; } if (data.is_trial === undefined) { data.is_trial = subscription.isTrial; } // we can add more fields but we should be carefull about which ones to add // since Amplitude is an external vendor } // debug mode will console.log the event parameters if (hasAnalyticsDebug()) { // eslint-disable-next-line no-console console.log('rawTrackAnalyticsEvent', {eventKey, eventName, ...data}); } // Prepare reloads data payload. If the organization_id is passed we include // that in the data payload. const user = ConfigStore.get('user'); const reloadData = { user_id: coerceNumber(user?.id), org_id: organization_id, allow_no_schema: true, sent_at: (time || Date.now()).toString(), ...data, }; trackReloadEvent(eventKey, reloadData); if (eventName && organization_id !== undefined) { const orgAge = getOrganizationAge(organization); const userAge = getUserAge(user); // add in url for amplitude events as reload has it automatically added const dataWithUrl = { url: window.location.href, user_age: userAge, organization_age: orgAge, ...data, }; trackAmplitudeEvent(eventName, organization_id, dataWithUrl, {time}); trackPendoEvent(eventName, data); } // using the eventName for marketing event names if (eventName && MARKETING_EVENT_NAMES.has(eventName)) { trackMarketingEvent(eventName, {plan: subscription?.plan}); } } catch (err) { // eslint-disable-next-line no-console console.error('Error tracking analytics event', err); } }