rawTrackAnalyticsEvent.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. import * as qs from 'query-string';
  2. import ConfigStore from 'sentry/stores/configStore';
  3. import type {Hooks} from 'sentry/types/hooks';
  4. import type {Organization} from 'sentry/types/organization';
  5. import type {User} from 'sentry/types/user';
  6. import getDaysSinceDate from 'sentry/utils/getDaysSinceDate';
  7. import {uniqueId} from 'sentry/utils/guid';
  8. import localStorage from 'sentry/utils/localStorage';
  9. import sessionStorage from 'sentry/utils/sessionStorage';
  10. import type {Subscription} from 'getsentry/types';
  11. import trackAmplitudeEvent from './trackAmplitudeEvent';
  12. import trackMarketingEvent from './trackMarketingEvent';
  13. import trackPendoEvent from './trackPendoEvent';
  14. import trackReloadEvent from './trackReloadEvent';
  15. /**
  16. * Fields that are listed here which are passed to trackAnalyticsEvent's data
  17. * will automatically be coerced into integers.
  18. */
  19. const COERCE_FIELDS = ['project_id', 'organization_id', 'user_id', 'org_id'];
  20. function coerceNumber(value: string | undefined | null) {
  21. const originalValue = value;
  22. if (value === undefined) {
  23. return undefined;
  24. }
  25. if (value === null) {
  26. return null;
  27. }
  28. // Attempt to coerce the value to a number
  29. const numberValue = Number(value);
  30. // Still unable to coerce to a number? that's a failure
  31. if (isNaN(numberValue)) {
  32. throw new Error(`Unable to coerce value to number: '${originalValue}'`);
  33. }
  34. return numberValue;
  35. }
  36. const MARKETING_EVENT_NAMES = new Set([
  37. 'Growth: Onboarding Load Choose Platform Page',
  38. 'Growth: Onboarding Choose Platform',
  39. 'Growth: Onboarding Start Onboarding',
  40. 'Growth: Onboarding View Sample Event',
  41. 'Growth: Onboarding Click Set Up Your Project',
  42. 'Growth: Onboarding Take to Error',
  43. 'Growth: Onboarding Clicked Need Help',
  44. ]);
  45. const ANALYTICS_SESSION = 'ANALYTICS_SESSION';
  46. const startAnalyticsSession = () => {
  47. const sessionId = uniqueId();
  48. sessionStorage.setItem(ANALYTICS_SESSION, sessionId);
  49. return sessionId;
  50. };
  51. const getAnalyticsSessionId = () => sessionStorage.getItem(ANALYTICS_SESSION);
  52. const hasAnalyticsDebug = () => localStorage.getItem('DEBUG_ANALYTICS_GETSENTRY') === '1';
  53. const getCustomReferrer = () => {
  54. try {
  55. // pull the referrer from the query parameter of the page
  56. const {referrer} = qs.parse(window.location.search) || {};
  57. if (referrer && typeof referrer === 'string') {
  58. return referrer;
  59. }
  60. } catch {
  61. // ignore if this fails to parse
  62. // this can happen if we have an invalid query string
  63. // e.g. unencoded "%"
  64. }
  65. return undefined;
  66. };
  67. const getOrganizationId = (
  68. organization: Organization | string | null
  69. ): number | undefined | null => {
  70. // this should never happen but there are components that use withOrganization
  71. // that might end up with an undefined org if used incorrectly
  72. if (organization === undefined) {
  73. // eslint-disable-next-line no-console
  74. console.warn('Unexpected undefined organization');
  75. return undefined;
  76. }
  77. if (typeof organization === 'string') {
  78. const orgId = Number(organization);
  79. if (isNaN(orgId)) {
  80. // eslint-disable-next-line no-console
  81. console.warn(`Invalid organization ID: ${organization}`);
  82. return undefined;
  83. }
  84. return orgId;
  85. }
  86. // if organization is null, organization_id needs to be null
  87. return organization === null ? null : Number(organization.id);
  88. };
  89. const getOrganizationAge = (
  90. organization: Organization | string | null
  91. ): number | null => {
  92. if (typeof organization === 'string') {
  93. return null;
  94. }
  95. if (typeof organization?.dateCreated === 'string') {
  96. const orgAge = getDaysSinceDate(organization?.dateCreated);
  97. return orgAge;
  98. }
  99. return null;
  100. };
  101. const getUserAge = (user: User): number => {
  102. return getDaysSinceDate(user.dateJoined);
  103. };
  104. type RawTrackEventHook = Hooks['analytics:raw-track-event'];
  105. type Params = Parameters<RawTrackEventHook>[0] & {
  106. subscription?: Subscription;
  107. };
  108. type Options = Parameters<RawTrackEventHook>[1];
  109. /**
  110. * Returns true if the organization input has all the properties of a full organization
  111. */
  112. function isFullOrganization(
  113. organization: Params['organization']
  114. ): organization is Organization {
  115. return !!organization && typeof organization !== 'string';
  116. }
  117. export default function rawTrackAnalyticsEvent(
  118. {eventKey, eventName, organization, subscription, ...data}: Params,
  119. options?: Options
  120. ) {
  121. try {
  122. // apply custom function map parameters
  123. const {mapValuesFn} = options || {};
  124. if (mapValuesFn) {
  125. data = mapValuesFn(data);
  126. }
  127. const time = options?.time;
  128. const organization_id = getOrganizationId(organization);
  129. // Coerce number fields
  130. Object.keys(data)
  131. .filter(field => COERCE_FIELDS.includes(field))
  132. .forEach(field => (data[field] = coerceNumber(data[field])));
  133. let sessionId = options?.startSession
  134. ? startAnalyticsSession()
  135. : getAnalyticsSessionId();
  136. // we should always have a session id but if we don't, we should generate one
  137. if (!sessionId) {
  138. sessionId = startAnalyticsSession();
  139. }
  140. data.analytics_session_id = sessionId;
  141. // add custom referrer if available
  142. const customReferrer = getCustomReferrer();
  143. if (customReferrer) {
  144. data.custom_referrer = customReferrer;
  145. }
  146. // add in previous referrer if different than custom referrer
  147. const prevReferrer = sessionStorage.getItem('previous_referrer');
  148. if (prevReferrer && prevReferrer !== customReferrer) {
  149. data.previous_referrer = prevReferrer;
  150. }
  151. // pass in properties if we have the full organization
  152. if (isFullOrganization(organization)) {
  153. data.role = organization.orgRole;
  154. }
  155. // add in plan information
  156. if (subscription) {
  157. data.plan = data.plan || subscription.plan;
  158. if (data.can_trial === undefined) {
  159. data.can_trial = subscription.canTrial;
  160. }
  161. if (data.is_trial === undefined) {
  162. data.is_trial = subscription.isTrial;
  163. }
  164. // we can add more fields but we should be carefull about which ones to add
  165. // since Amplitude is an external vendor
  166. }
  167. // debug mode will console.log the event parameters
  168. if (hasAnalyticsDebug()) {
  169. // eslint-disable-next-line no-console
  170. console.log('rawTrackAnalyticsEvent', {eventKey, eventName, ...data});
  171. }
  172. // Prepare reloads data payload. If the organization_id is passed we include
  173. // that in the data payload.
  174. const user = ConfigStore.get('user');
  175. const reloadData = {
  176. user_id: coerceNumber(user?.id),
  177. org_id: organization_id,
  178. allow_no_schema: true,
  179. sent_at: (time || Date.now()).toString(),
  180. ...data,
  181. };
  182. trackReloadEvent(eventKey, reloadData);
  183. if (eventName && organization_id !== undefined) {
  184. const orgAge = getOrganizationAge(organization);
  185. const userAge = getUserAge(user);
  186. // add in url for amplitude events as reload has it automatically added
  187. const dataWithUrl = {
  188. url: window.location.href,
  189. user_age: userAge,
  190. organization_age: orgAge,
  191. ...data,
  192. };
  193. trackAmplitudeEvent(eventName, organization_id, dataWithUrl, {time});
  194. trackPendoEvent(eventName, data);
  195. }
  196. // using the eventName for marketing event names
  197. if (eventName && MARKETING_EVENT_NAMES.has(eventName)) {
  198. trackMarketingEvent(eventName, {plan: subscription?.plan});
  199. }
  200. } catch (err) {
  201. // eslint-disable-next-line no-console
  202. console.error('Error tracking analytics event', err);
  203. }
  204. }