Path: blob/main/components/dashboard/src/Analytics.tsx
2498 views
/**1* Copyright (c) 2021 Gitpod GmbH. All rights reserved.2* Licensed under the GNU Affero General Public License (AGPL).3* See License.AGPL.txt in the project root for license information.4*/56import { log } from "@gitpod/gitpod-protocol/lib/util/logging";7import Cookies from "js-cookie";8import { v4 } from "uuid";9import { StartWorkspaceError } from "./start/StartPage";10import { RemoteTrackMessage } from "@gitpod/gitpod-protocol/lib/analytics";1112export type Event =13| "invite_url_requested"14| "organisation_authorised"15| "dotfile_repo_changed"16| "feedback_submitted"17| "workspace_class_changed"18| "privacy_policy_update_accepted"19| "browser_extension_promotion_interaction"20| "coachmark_dismissed"21| "modal_dismiss"22| "ide_configuration_changed"23| "status_rendered"24| "error_rendered"25| "video_clicked"26| "waitlist_joined";27type InternalEvent = Event | "path_changed" | "dashboard_clicked";2829export type EventProperties =30| TrackOrgAuthorised31| TrackInviteUrlRequested32| TrackDotfileRepo33| TrackFeedback34| TrackPolicyUpdateClick35| TrackBrowserExtensionPromotionInteraction36| TrackModalDismiss37| TrackIDEConfigurationChanged38| TrackWorkspaceClassChanged39| TrackStatusRendered40| TrackErrorRendered41| TrackVideoClicked42| TrackWaitlistJoined;43type InternalEventProperties = EventProperties | TrackDashboardClick | TrackPathChanged;4445export interface TrackErrorRendered {46sessionId: string;47instanceId?: string;48workspaceId: string;49type: string;50error: any;51}5253export interface TrackStatusRendered {54sessionId: string;55instanceId?: string;56workspaceId: string;57type: string;58phase?: string;59}6061export interface TrackWorkspaceClassChanged {}62export interface TrackIDEConfigurationChanged {63location: string;64name?: string;65version?: string;66}67export interface TrackModalDismiss {68manner: string;69title?: string;70specify?: string;71path: string;72}7374export interface TrackOrgAuthorised {75installation_id: string;76setup_action: string | undefined;77}7879export interface TrackInviteUrlRequested {80invite_url: string;81}8283export interface TrackDotfileRepo {84previous?: string;85current: string;86}8788export interface TrackFeedback {89score: number;90feedback: string;91href: string;92path: string;93error_object?: StartWorkspaceError;94error_message?: string;95}9697export interface TrackPolicyUpdateClick {98path: string;99success: boolean;100}101102export interface TrackCoachmarkDismissed {103name: string;104success: boolean;105}106107export interface TrackBrowserExtensionPromotionInteraction {108action: "chrome_navigation" | "firefox_navigation" | "manually_dismissed";109}110111export interface TrackVideoClicked {112context: string;113path: string;114}115116interface TrackDashboardClick {117dnt?: boolean;118path: string;119button_type?: string;120label?: string;121destination?: string;122}123124interface TrackPathChanged {125prev: string;126path: string;127}128129export interface TrackWaitlistJoined {130email: string;131feature: string;132}133134interface Traits {135unsubscribed_onboarding?: boolean;136unsubscribed_changelog?: boolean;137unsubscribed_devx?: boolean;138}139140//call this to track all events outside of button and anchor clicks141export function trackEvent(event: "invite_url_requested", properties: TrackInviteUrlRequested): void;142export function trackEvent(event: "organisation_authorised", properties: TrackOrgAuthorised): void;143export function trackEvent(event: "dotfile_repo_changed", properties: TrackDotfileRepo): void;144export function trackEvent(event: "feedback_submitted", properties: TrackFeedback): void;145export function trackEvent(event: "workspace_class_changed", properties: TrackWorkspaceClassChanged): void;146export function trackEvent(event: "privacy_policy_update_accepted", properties: TrackPolicyUpdateClick): void;147export function trackEvent(event: "coachmark_dismissed", properties: TrackCoachmarkDismissed): void;148export function trackEvent(149event: "browser_extension_promotion_interaction",150properties: TrackBrowserExtensionPromotionInteraction,151): void;152export function trackEvent(event: "modal_dismiss", properties: TrackModalDismiss): void;153export function trackEvent(event: "ide_configuration_changed", properties: TrackIDEConfigurationChanged): void;154export function trackEvent(event: "status_rendered", properties: TrackStatusRendered): void;155export function trackEvent(event: "error_rendered", properties: TrackErrorRendered): void;156export function trackEvent(event: "video_clicked", properties: TrackVideoClicked): void;157export function trackEvent(event: "waitlist_joined", properties: TrackWaitlistJoined): void;158export function trackEvent(event: Event, properties: EventProperties): void {159trackEventInternal(event, properties);160}161162const trackEventInternal = (event: InternalEvent, properties: InternalEventProperties) => {163sendTrackEvent({164anonymousId: getAnonymousId(),165event,166properties,167});168};169170// Please use trackEvent instead of this function171export function sendTrackEvent(message: RemoteTrackMessage): void {172sendAnalytics("trackEvent", message);173}174175export function trackVideoClick(context: string) {176trackEvent("video_clicked", {177context: context,178path: window.location.pathname,179});180}181182export const trackButtonOrAnchor = (target: HTMLAnchorElement | HTMLButtonElement | HTMLDivElement) => {183//read manually passed analytics props from 'data-analytics' attribute of event target184let passedProps: TrackDashboardClick | undefined;185if (target.dataset.analytics) {186try {187passedProps = JSON.parse(target.dataset.analytics) as TrackDashboardClick;188if (passedProps.dnt) {189return;190}191} catch (error) {192log.debug(error);193}194}195196let trackingMsg: TrackDashboardClick = {197path: window.location.pathname,198label: target.ariaLabel || target.textContent || undefined,199};200201if (target instanceof HTMLButtonElement || target instanceof HTMLDivElement) {202//parse button data203if (target.classList.contains("secondary")) {204trackingMsg.button_type = "secondary";205} else {206trackingMsg.button_type = "primary"; //primary button is the default if secondary is not specified207}208//retrieve href if parent is an anchor element209if (target.parentElement instanceof HTMLAnchorElement) {210const anchor = target.parentElement as HTMLAnchorElement;211trackingMsg.destination = anchor.href;212}213}214215if (target instanceof HTMLAnchorElement) {216const anchor = target as HTMLAnchorElement;217trackingMsg.destination = anchor.href;218}219220const getAncestorProps = (curr: HTMLElement | null): TrackDashboardClick | undefined => {221if (!curr || curr instanceof Document) {222return;223}224const ancestorProps: TrackDashboardClick | undefined = getAncestorProps(curr.parentElement);225const currProps = JSON.parse(curr.dataset.analytics || "{}") as TrackDashboardClick;226return { ...ancestorProps, ...currProps };227};228229const ancestorProps = getAncestorProps(target);230231//props that were passed directly to the event target take precedence over those passed to ancestor elements, which take precedence over those implicitly determined.232trackingMsg = { ...trackingMsg, ...ancestorProps, ...passedProps };233234trackEventInternal("dashboard_clicked", trackingMsg);235};236237//call this when the path changes. Complete page call is unnecessary for SPA after initial call238export const trackPathChange = (props: TrackPathChanged) => {239trackEventInternal("path_changed", props);240};241242type TrackLocationProperties = {243referrer: string;244path: string;245host: string;246url: string;247};248249export const trackLocation = async (includePII: boolean) => {250const props: TrackLocationProperties = {251referrer: document.referrer,252path: window.location.pathname,253host: window.location.hostname,254url: window.location.href,255};256257sendAnalytics("trackLocation", {258//if the user is authenticated, let server determine the id. else, pass anonymousId explicitly.259includePII: includePII,260anonymousId: getAnonymousId(),261properties: props,262});263};264265export const identifyUser = async (traits: Traits) => {266sendAnalytics("identifyUser", {267anonymousId: getAnonymousId(),268traits: traits,269});270};271272function sendAnalytics(operation: "trackEvent" | "trackLocation" | "identifyUser", message: any) {273fetch("/_analytics/" + operation, {274method: "POST",275headers: {276"Content-Type": "application/json",277},278body: JSON.stringify(message),279credentials: "include",280});281}282283const getCookieConsent = () => {284return Cookies.get("gp-analytical") === "true";285};286287const getAnonymousId = (): string | undefined => {288if (!getCookieConsent()) {289//we do not want to read or set the id cookie if we don't have consent290return;291}292let anonymousId = Cookies.get("ajs_anonymous_id");293if (anonymousId) {294return anonymousId.replace(/^"(.+(?="$))"$/, "$1"); //strip enclosing double quotes before returning295} else {296anonymousId = v4();297Cookies.set("ajs_anonymous_id", anonymousId, { domain: "." + window.location.hostname, expires: 365 });298}299return anonymousId;300};301302303