Google Analytics events for opening and closing the app, and opening and closing projects (#9779)

- Closes #9778
- Add `open_app`, `close_app`, `open_workflow`, and `close_workflow` events
- Miscellaneous fixes for Google Analytics:
- Fix `open_chat` and `close_chat` events firing even when chat is not visible
- Add Google Analytics script to GUI2 entrypoint (i.e. the entrypoint used by the desktop app)

Unrelated changes:
- Add Nix development shell to allow Nix users to build GUI2 and the build script
- Java dependencies have *not* been added in this PR to keep things simple

# Important Notes
None
This commit is contained in:
somebody1234 2024-04-25 21:33:59 +10:00 committed by GitHub
parent 0d495ffd97
commit 3a53d470eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 352 additions and 60 deletions

7
.envrc Normal file
View File

@ -0,0 +1,7 @@
strict_env
if ! has nix_direnv_version || ! nix_direnv_version 3.0.4; then
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.4/direnvrc" "sha256-DzlYZ33mWF/Gs8DDeyjr8mnVmQGx7ASYqA5WlxwvBG4="
fi
use flake

6
.gitignore vendored
View File

@ -162,3 +162,9 @@ test-results
*.ir
*.meta
.enso/
##################
## direnv cache ##
##################
.direnv

View File

@ -8,6 +8,7 @@
"./src/appConfig": "./src/appConfig.js",
"./src/buildUtils": "./src/buildUtils.js",
"./src/detect": "./src/detect.ts",
"./src/gtag": "./src/gtag.ts"
"./src/gtag": "./src/gtag.ts",
"./src/load": "./src/load.ts"
}
}

View File

@ -3,6 +3,12 @@ import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as url from 'node:url'
// =================
// === Constants ===
// =================
const ENSO_CLOUD_GOOGLE_ANALYTICS_TAG = 'G-CLTBJ37MDM'
// ===============================
// === readEnvironmentFromFile ===
// ===============================
@ -35,6 +41,10 @@ export async function readEnvironmentFromFile() {
}
const variables = Object.fromEntries(entries)
Object.assign(process.env, variables)
if (!('' in process.env)) {
// @ts-expect-error This is the only place where this environment variable is set.
process.env.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG = ENSO_CLOUD_GOOGLE_ANALYTICS_TAG
}
} catch (error) {
if (isProduction) {
console.warn('Could not load `.env` file; disabling cloud backend.')

View File

@ -17,15 +17,27 @@ export enum Platform {
windows = 'Windows',
macOS = 'macOS',
linux = 'Linux',
windowsPhone = 'Windows Phone',
iPhoneOS = 'iPhone OS',
android = 'Android',
}
/** Return the platform the app is currently running on.
/** The platform the app is currently running on.
* This is used to determine whether `metaKey` or `ctrlKey` is used in shortcuts. */
export function platform(): Platform {
if (isOnWindows()) {
export function platform() {
if (isOnWindowsPhone()) {
// MUST be before Android and Windows.
return Platform.windowsPhone
} else if (isOnWindows()) {
return Platform.windows
} else if (isOnIPhoneOS()) {
// MUST be before macOS.
return Platform.iPhoneOS
} else if (isOnMacOS()) {
return Platform.macOS
} else if (isOnAndroid()) {
// MUST be before Linux.
return Platform.android
} else if (isOnLinux()) {
return Platform.linux
} else {
@ -33,22 +45,37 @@ export function platform(): Platform {
}
}
/** Return whether the device is running Windows. */
/** Whether the device is running Windows. */
export function isOnWindows() {
return /windows/i.test(navigator.userAgent)
}
/** Return whether the device is running macOS. */
/** Whether the device is running macOS. */
export function isOnMacOS() {
return /mac os/i.test(navigator.userAgent)
}
/** Return whether the device is running Linux. */
/** Whether the device is running Linux. */
export function isOnLinux() {
return /linux/i.test(navigator.userAgent)
}
/** Return whether the device is running an unknown OS. */
/** Whether the device is running Windows Phone. */
export function isOnWindowsPhone() {
return /windows phone/i.test(navigator.userAgent)
}
/** Whether the device is running iPhone OS. */
export function isOnIPhoneOS() {
return /iPhone/i.test(navigator.userAgent)
}
/** Whether the device is running Android. */
export function isOnAndroid() {
return /android/i.test(navigator.userAgent)
}
/** Whether the device is running an unknown OS. */
export function isOnUnknownOS() {
return platform() === Platform.unknown
}
@ -126,3 +153,73 @@ export function isOnSafari() {
export function isOnUnknownBrowser() {
return browser() === Browser.unknown
}
// ====================
// === Architecture ===
// ====================
let detectedArchitecture: string | null = null
// Only implemented by Chromium.
// @ts-expect-error This API exists, but no typings exist for it yet.
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
navigator.userAgentData
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
?.getHighEntropyValues(['architecture'])
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
.then((values: unknown) => {
if (
typeof values === 'object' &&
values != null &&
'architecture' in values &&
typeof values.architecture === 'string'
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
detectedArchitecture = String(values.architecture)
}
})
/** Possible processor architectures. */
export enum Architecture {
intel64 = 'x86_64',
arm64 = 'arm64',
}
/** The processor architecture of the current system. */
export function architecture() {
if (detectedArchitecture != null) {
switch (detectedArchitecture) {
case 'arm': {
return Architecture.arm64
}
default: {
return Architecture.intel64
}
}
}
switch (platform()) {
case Platform.windows:
case Platform.linux:
case Platform.unknown: {
return Architecture.intel64
}
case Platform.macOS:
case Platform.iPhoneOS:
case Platform.android:
case Platform.windowsPhone: {
// Assume the macOS device is on a M-series CPU.
// This is highly unreliable, but operates under the assumption that all
// new macOS devices will be ARM64.
return Architecture.arm64
}
}
}
/** Whether the device has an Intel 64-bit CPU. */
export function isIntel64() {
return architecture() === Architecture.intel64
}
/** Whether the device has an ARM 64-bit CPU. */
export function isArm64() {
return architecture() === Architecture.arm64
}

View File

@ -1,4 +1,11 @@
/** @file Google Analytics tag. */
import * as load from './load'
if (process.env.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG != null) {
void load.loadScript(
`https://www.googletagmanager.com/gtag/js?id=${process.env.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG}`
)
}
// @ts-expect-error This is explicitly not given types as it is a mistake to acess this
// anywhere else.

View File

@ -0,0 +1,32 @@
/** @file Utilities for loading resources. */
/** Add a script to the DOM. */
export function loadScript(url: string) {
const script = document.createElement('script')
script.crossOrigin = 'anonymous'
script.src = url
document.head.appendChild(script)
return new Promise<HTMLScriptElement>((resolve, reject) => {
script.onload = () => {
resolve(script)
}
script.onerror = reject
})
}
/** Add a CSS stylesheet to the DOM. */
export function loadStyle(url: string) {
const style = document.createElement('link')
style.crossOrigin = 'anonymous'
style.href = url
style.rel = 'stylesheet'
style.media = 'screen'
style.type = 'text/css'
document.head.appendChild(style)
return new Promise<HTMLLinkElement>((resolve, reject) => {
style.onload = () => {
resolve(style)
}
style.onerror = reject
})
}

View File

@ -42,10 +42,5 @@
<noscript>
This page requires JavaScript to run. Please enable it in your browser.
</noscript>
<!-- Google tag (gtag.js) -->
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-CLTBJ37MDM"
></script>
</body>
</html>

View File

@ -43,10 +43,5 @@
<noscript>
This page requires JavaScript to run. Please enable it in your browser.
</noscript>
<!-- Google tag (gtag.js) -->
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-CLTBJ37MDM"
></script>
</body>
</html>

View File

@ -70,6 +70,9 @@ const MODIFIER_JSX: Readonly<
</aria.Text>
),
},
[detect.Platform.iPhoneOS]: {},
[detect.Platform.android]: {},
[detect.Platform.windowsPhone]: {},
/* eslint-enable @typescript-eslint/naming-convention */
}

View File

@ -5,9 +5,9 @@ import * as gtag from 'enso-common/src/gtag'
import * as authProvider from '#/providers/AuthProvider'
// ===================
// === useGtag ===
// ===================
// ====================
// === useGtagEvent ===
// ====================
/** A hook that returns a no-op if the user is offline, otherwise it returns
* a transparent wrapper around `gtag.event`. */
@ -22,3 +22,28 @@ export function useGtagEvent() {
[sessionType]
)
}
// =============================
// === gtagOpenCloseCallback ===
// =============================
/** Send an event indicating that something has been opened, and return a cleanup function
* sending an event indicating that it has been closed.
*
* Also sends the close event when the window is unloaded. */
export function gtagOpenCloseCallback(
gtagEventRef: React.MutableRefObject<ReturnType<typeof useGtagEvent>>,
openEvent: string,
closeEvent: string
) {
const gtagEventCurrent = gtagEventRef.current
gtagEventCurrent(openEvent)
const onBeforeUnload = () => {
gtagEventCurrent(closeEvent)
}
window.addEventListener('beforeunload', onBeforeUnload)
return () => {
window.removeEventListener('beforeunload', onBeforeUnload)
gtagEventCurrent(closeEvent)
}
}

View File

@ -243,7 +243,6 @@ interface InternalChatHeaderProps {
function ChatHeader(props: InternalChatHeaderProps) {
const { threads, setThreads, threadId, threadTitle, setThreadTitle } = props
const { switchThread, sendMessage, doClose } = props
const gtagEvent = gtagHooks.useGtagEvent()
const [isThreadListVisible, setIsThreadListVisible] = React.useState(false)
// These will never be `null` as their values are set immediately.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@ -258,12 +257,10 @@ function ChatHeader(props: InternalChatHeaderProps) {
setIsThreadListVisible(false)
}
document.addEventListener('click', onClick)
gtagEvent('cloud_open_chat')
return () => {
document.removeEventListener('click', onClick)
gtagEvent('cloud_close_chat')
}
}, [gtagEvent])
}, [])
return (
<>
@ -394,6 +391,17 @@ export default function Chat(props: ChatProps) {
}
},
})
const gtagEvent = gtagHooks.useGtagEvent()
const gtagEventRef = React.useRef(gtagEvent)
gtagEventRef.current = gtagEvent
React.useEffect(() => {
if (!isOpen) {
return
} else {
return gtagHooks.gtagOpenCloseCallback(gtagEventRef, 'cloud_open_chat', 'cloud_close_chat')
}
}, [isOpen])
/** This is SAFE, because this component is only rendered when `accessToken` is present.
* See `dashboard.tsx` for its sole usage. */

View File

@ -1,14 +1,15 @@
/** @file The container that launches the IDE. */
import * as React from 'react'
import * as load from 'enso-common/src/load'
import * as appUtils from '#/appUtils'
import * as gtagHooks from '#/hooks/gtagHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as backendModule from '#/services/Backend'
import * as load from '#/utilities/load'
// =================
// === Constants ===
// =================
@ -39,6 +40,9 @@ export interface EditorProps {
export default function Editor(props: EditorProps) {
const { hidden, supportsLocalBackend, projectStartupInfo, appRunner } = props
const toastAndLog = toastAndLogHooks.useToastAndLog()
const gtagEvent = gtagHooks.useGtagEvent()
const gtagEventRef = React.useRef(gtagEvent)
gtagEventRef.current = gtagEvent
const [initialized, setInitialized] = React.useState(supportsLocalBackend)
React.useEffect(() => {
@ -48,6 +52,14 @@ export default function Editor(props: EditorProps) {
}
}, [hidden])
React.useEffect(() => {
if (hidden) {
return
} else {
return gtagHooks.gtagOpenCloseCallback(gtagEventRef, 'open_workflow', 'close_workflow')
}
}, [projectStartupInfo, hidden])
let hasEffectRun = false
React.useEffect(() => {

View File

@ -6,6 +6,8 @@ import type * as stripeTypes from '@stripe/stripe-js'
import * as stripe from '@stripe/stripe-js/pure'
import * as toast from 'react-toastify'
import * as load from 'enso-common/src/load'
import * as appUtils from '#/appUtils'
import type * as text from '#/text'
@ -20,7 +22,6 @@ import UnstyledButton from '#/components/UnstyledButton'
import * as backendModule from '#/services/Backend'
import * as load from '#/utilities/load'
import * as string from '#/utilities/string'
// =================

View File

@ -10,10 +10,13 @@ import isNetworkError from 'is-network-error'
import * as router from 'react-router-dom'
import * as toast from 'react-toastify'
import * as detect from 'enso-common/src/detect'
import * as gtag from 'enso-common/src/gtag'
import * as appUtils from '#/appUtils'
import * as gtagHooks from '#/hooks/gtagHooks'
import * as backendProvider from '#/providers/BackendProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as loggerProvider from '#/providers/LoggerProvider'
@ -238,6 +241,16 @@ export default function AuthProvider(props: AuthProviderProps) {
},
[userSession?.type]
)
const gtagEventRef = React.useRef(gtagEvent)
gtagEventRef.current = gtagEvent
React.useEffect(() => {
gtag.gtag('set', {
platform: detect.platform(),
architecture: detect.architecture(),
})
return gtagHooks.gtagOpenCloseCallback(gtagEventRef, 'open_app', 'close_app')
}, [])
// This is identical to `hooks.useOnlineCheck`, however it is inline here to avoid any possible
// circular dependency.

View File

@ -1,32 +0,0 @@
/** @file Utilities for loading resources. */
/** Add a script to the DOM. */
export function loadScript(url: string) {
const script = document.createElement('script')
script.crossOrigin = 'anonymous'
script.src = url
document.head.appendChild(script)
return new Promise<HTMLScriptElement>((resolve, reject) => {
script.onload = () => {
resolve(script)
}
script.onerror = reject
})
}
/** Add a CSS stylesheet to the DOM. */
export function loadStyle(url: string) {
const style = document.createElement('link')
style.crossOrigin = 'anonymous'
style.href = url
style.rel = 'stylesheet'
style.media = 'screen'
style.type = 'text/css'
document.head.appendChild(style)
return new Promise<HTMLLinkElement>((resolve, reject) => {
style.onload = () => {
resolve(style)
}
style.onerror = reject
})
}

View File

@ -140,6 +140,8 @@ declare global {
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly ENSO_CLOUD_COGNITO_REGION?: string
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly ENSO_CLOUD_GOOGLE_ANALYTICS_TAG?: string
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly ENSO_SUPPORTS_VIBRANCY?: string
// === Electron watch script variables ===

64
flake.lock Normal file
View File

@ -0,0 +1,64 @@
{
"nodes": {
"fenix": {
"inputs": {
"nixpkgs": ["nixpkgs"],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1713939967,
"narHash": "sha256-3YQSEYvAIHE40tx5nM9dgeEe0gsHjf15+gurUpyDYNw=",
"owner": "nix-community",
"repo": "fenix",
"rev": "5c3ff469526a6ca54a887fbda9d67aef4dd4a921",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1713805509,
"narHash": "sha256-YgSEan4CcrjivCNO5ZNzhg7/8ViLkZ4CB/GrGBVSudo=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "1e1dc66fe68972a76679644a5577828b6a7e8be4",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"fenix": "fenix",
"nixpkgs": "nixpkgs"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1713801366,
"narHash": "sha256-VmzP5s59kb6//mj+ES+hslTLuugjd7OluGIXXcwuyHg=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "e31c9f3fe11148514c3ad254b639b2ed7dbe35de",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

46
flake.nix Normal file
View File

@ -0,0 +1,46 @@
{
inputs = {
nixpkgs.url = github:nixos/nixpkgs/nixpkgs-unstable;
fenix.url = github:nix-community/fenix;
fenix.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, fenix }:
let
forAllSystems = with nixpkgs.lib; f: foldAttrs mergeAttrs { }
(map (s: { ${s} = f s; }) systems.flakeExposed);
in
{
devShell = forAllSystems
(system:
let
pkgs = nixpkgs.legacyPackages.${system};
rust = fenix.packages.${system}.fromToolchainFile {
dir = ./.;
sha256 = "sha256-o/MRwGYjLPyD1zZQe3LX0dOynwRJpVygfF9+vSnqTOc=";
};
in
pkgs.mkShell {
packages = with pkgs; [
# === TypeScript dependencies ===
nodejs_20 # should match the Node.JS version of the lambdas
corepack
# === Electron ===
electron
# === node-gyp dependencies ===
python3
gnumake
# === WASM parser dependencies ===
rust
wasm-pack
# Java and SBT omitted for now
];
shellHook = ''
# `sccache` can be used to speed up compile times for Rust crates.
# `~/.cargo/bin/sccache` is provided by `cargo install sccache`.
# `~/.cargo/bin` must be in the `PATH` for the binary to be accessible.
export PATH=$HOME/.cargo/bin:$PATH
'';
});
};
}