diff --git a/static/app/constants/index.tsx b/static/app/constants/index.tsx index 0fb79f2118c5e6..e0c57034ac1819 100644 --- a/static/app/constants/index.tsx +++ b/static/app/constants/index.tsx @@ -21,6 +21,10 @@ export const CUSTOMER_DOMAIN = ? undefined : window?.__initialData?.customerDomain?.subdomain; +// Constant used for tracking referrer in session storage rather than +// ?referrer=foo get parameter: +export const CUSTOM_REFERRER_KEY = 'customReferrer'; + // This is considered the "default" route/view that users should be taken // to when the application does not have any further context // diff --git a/static/app/utils/useSessionStorage.tsx b/static/app/utils/useSessionStorage.tsx index 6030517c257adb..014a8a823d4a09 100644 --- a/static/app/utils/useSessionStorage.tsx +++ b/static/app/utils/useSessionStorage.tsx @@ -4,7 +4,7 @@ import sessionStorageWrapper from 'sentry/utils/sessionStorage'; const isBrowser = typeof window !== 'undefined'; -function readStorageValue(key: string, initialValue: T) { +export function readStorageValue(key: string, initialValue: T) { const value = sessionStorageWrapper.getItem(key); // We check for 'undefined' because the value may have diff --git a/static/app/views/nav/orgDropdown.spec.tsx b/static/app/views/nav/orgDropdown.spec.tsx index beec54c4fd9c2e..658683fb491924 100644 --- a/static/app/views/nav/orgDropdown.spec.tsx +++ b/static/app/views/nav/orgDropdown.spec.tsx @@ -3,8 +3,10 @@ import {UserFixture} from 'sentry-fixture/user'; import {render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary'; +import {CUSTOM_REFERRER_KEY} from 'sentry/constants'; import ConfigStore from 'sentry/stores/configStore'; import OrganizationsStore from 'sentry/stores/organizationsStore'; +import {readStorageValue} from 'sentry/utils/useSessionStorage'; import {OrgDropdown} from 'sentry/views/nav/orgDropdown'; describe('OrgDropdown', () => { @@ -102,4 +104,18 @@ describe('OrgDropdown', () => { within(inactiveGroup).getByRole('menuitemradio', {name: /Deleting org/}) ).toBeInTheDocument(); }); + + it('clicking project sets referrer in session storage', async () => { + render(, {organization}); + await userEvent.click(screen.getByRole('button', {name: 'Toggle organization menu'})); + // We use onAction to navigate, no href is set: + expect(screen.getByRole('menuitemradio', {name: 'Projects'})).not.toHaveAttribute( + 'href' + ); + // onClick should take precedence setting session storage value and navigating: + await userEvent.click(screen.getByRole('menuitemradio', {name: 'Projects'})); + expect(readStorageValue(CUSTOM_REFERRER_KEY, null)).toBe( + 'org-dropdown' + ); + }); }); diff --git a/static/app/views/nav/orgDropdown.tsx b/static/app/views/nav/orgDropdown.tsx index 9bad4073a480c2..2e2f833a6c7cd4 100644 --- a/static/app/views/nav/orgDropdown.tsx +++ b/static/app/views/nav/orgDropdown.tsx @@ -8,6 +8,7 @@ import {Button} from 'sentry/components/core/button'; import {DropdownMenu, type MenuItemProps} from 'sentry/components/dropdownMenu'; import OrganizationBadge from 'sentry/components/idBadge/organizationBadge'; import QuestionTooltip from 'sentry/components/questionTooltip'; +import {CUSTOM_REFERRER_KEY} from 'sentry/constants'; import {IconAdd} from 'sentry/icons'; import {t, tn} from 'sentry/locale'; import ConfigStore from 'sentry/stores/configStore'; @@ -16,12 +17,16 @@ import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import type {Organization} from 'sentry/types/organization'; import {isDemoModeActive} from 'sentry/utils/demoMode'; import {localizeDomain, resolveRoute} from 'sentry/utils/resolveRoute'; +import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; import useProjects from 'sentry/utils/useProjects'; +import {useSessionStorage} from 'sentry/utils/useSessionStorage'; import {useNavContext} from 'sentry/views/nav/context'; import {NavLayout} from 'sentry/views/nav/types'; import {makeProjectsPathname} from 'sentry/views/projects/pathname'; +const ORG_DROPDOWN_REFERRER = 'org-dropdown'; + function createOrganizationMenuItem(): MenuItemProps { const configFeatures = ConfigStore.get('features'); const sentryUrl = localizeDomain(ConfigStore.get('links').sentryUrl); @@ -58,6 +63,8 @@ export function OrgDropdown({ }) { const config = useLegacyStore(ConfigStore); const organization = useOrganization(); + const navigate = useNavigate(); + const [, setReferrer] = useSessionStorage(CUSTOM_REFERRER_KEY, null); // It's possible we do not have an org in context (e.g. RouteNotFound) // Otherwise, we should have the full org @@ -141,7 +148,10 @@ export function OrgDropdown({ { key: 'projects', label: t('Projects'), - to: makeProjectsPathname({path: '/', organization}), + onAction: () => { + setReferrer(ORG_DROPDOWN_REFERRER); + navigate(makeProjectsPathname({path: '/', organization})); + }, hidden: hideOrgLinks, }, { diff --git a/static/gsApp/utils/rawTrackAnalyticsEvent.spec.tsx b/static/gsApp/utils/rawTrackAnalyticsEvent.spec.tsx index a937f0b5b7c0f4..150cd899a4d363 100644 --- a/static/gsApp/utils/rawTrackAnalyticsEvent.spec.tsx +++ b/static/gsApp/utils/rawTrackAnalyticsEvent.spec.tsx @@ -3,6 +3,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; import {setWindowLocation} from 'sentry-test/utils'; +import {CUSTOM_REFERRER_KEY} from 'sentry/constants'; import ConfigStore from 'sentry/stores/configStore'; import {uniqueId} from 'sentry/utils/guid'; import sessionStorage from 'sentry/utils/sessionStorage'; @@ -184,6 +185,31 @@ describe('rawTrackAnalyticsEvent', () => { ); setWindowLocation('http:/localhost/'); }); + + it('sets custom_referrer if found in local storage', () => { + sessionStorage.setItem(CUSTOM_REFERRER_KEY, JSON.stringify('batman')); + setWindowLocation('http:/localhost'); + rawTrackAnalyticsEvent({ + eventKey: 'test_event', + eventName: 'Test Event', + organization, + }); + + expect(trackReloadEvent).toHaveBeenCalledWith( + 'test_event', + expect.objectContaining({custom_referrer: 'batman'}) + ); + + expect(trackAmplitudeEvent).toHaveBeenCalledWith( + 'Test Event', + org_id, + expect.objectContaining({custom_referrer: 'batman'}), + {time: undefined} + ); + setWindowLocation('http:/localhost/'); + expect(sessionStorage.getItem(CUSTOM_REFERRER_KEY)).toBeNull(); + }); + it('start analytics session', () => { rawTrackAnalyticsEvent( { diff --git a/static/gsApp/utils/rawTrackAnalyticsEvent.tsx b/static/gsApp/utils/rawTrackAnalyticsEvent.tsx index 520f98b8284265..5d60d4ebd47b68 100644 --- a/static/gsApp/utils/rawTrackAnalyticsEvent.tsx +++ b/static/gsApp/utils/rawTrackAnalyticsEvent.tsx @@ -1,5 +1,6 @@ import * as qs from 'query-string'; +import {CUSTOM_REFERRER_KEY} from 'sentry/constants'; import ConfigStore from 'sentry/stores/configStore'; import type {Hooks} from 'sentry/types/hooks'; import type {Organization} from 'sentry/types/organization'; @@ -8,6 +9,7 @@ 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 {readStorageValue} from 'sentry/utils/useSessionStorage'; import type {Subscription} from 'getsentry/types'; import {hasNewBillingUI} from 'getsentry/utils/billing'; @@ -73,9 +75,16 @@ const getCustomReferrer = () => { try { // pull the referrer from the query parameter of the page const {referrer} = qs.parse(window.location.search) || {}; + // pull the referrer from session storage. + const storedReferrer = readStorageValue(CUSTOM_REFERRER_KEY, null); + // ?referrer takes precedence, but still unset session stored referrer. + if (storedReferrer) { + sessionStorage.removeItem(CUSTOM_REFERRER_KEY); + } if (referrer && typeof referrer === 'string') { return referrer; } + return storedReferrer; } catch { // ignore if this fails to parse // this can happen if we have an invalid query string