diff --git a/chart/templates/frontend.yaml b/chart/templates/frontend.yaml index 261b4fec0f..d90ddd7a88 100644 --- a/chart/templates/frontend.yaml +++ b/chart/templates/frontend.yaml @@ -62,9 +62,9 @@ spec: value: "{{ .Values.minio_local_bucket_name }}" {{- end }} - {{- if .Values.inject_analytics }} - - name: INJECT_ANALYTICS - value: {{ .Values.inject_analytics }} + {{- if .Values.inject_extra }} + - name: INJECT_EXTRA + value: {{ .Values.inject_extra }} {{- end }} resources: diff --git a/chart/values.yaml b/chart/values.yaml index 9ec7fb98b5..0ba7eefd04 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -451,9 +451,9 @@ ingress: ingress_class: nginx -# Optional: Analytics injection script -# This runs as a blocking script on the frontend, so usually you'll want to have it just add a single script tag to the page with the `defer` attribute. -# inject_analytics: // your analytics injection script here +# Optional: Front-end injected script +# This runs as a blocking script on the frontend, so usually you'll want to have it just add a single script tag to the page with the `defer` attribute. Useful for things like analytics and bug tracking. +# inject_extra: // your front-end injected script # Signing Options diff --git a/frontend/00-browsertrix-nginx-init.sh b/frontend/00-browsertrix-nginx-init.sh index c5c112d7d9..a833051d0e 100755 --- a/frontend/00-browsertrix-nginx-init.sh +++ b/frontend/00-browsertrix-nginx-init.sh @@ -5,7 +5,7 @@ rm /etc/nginx/conf.d/default.conf if [ -z "$LOCAL_MINIO_HOST" ]; then echo "no local minio, clearing out minio route" - echo "" > /etc/nginx/includes/minio.conf + echo "" >/etc/nginx/includes/minio.conf else echo "local minio: replacing \$LOCAL_MINIO_HOST with \"$LOCAL_MINIO_HOST\", \$LOCAL_BUCKET with \"$LOCAL_BUCKET\"" sed -i "s/\$LOCAL_MINIO_HOST/$LOCAL_MINIO_HOST/g" /etc/nginx/includes/minio.conf @@ -13,15 +13,15 @@ else fi # Add analytics script, if provided -if [ -z "$INJECT_ANALYTICS" ]; then +if [ -z "$INJECT_EXTRA" ]; then echo "analytics disabled, injecting blank script" - echo "" > /usr/share/nginx/html/extra.js + echo "" >/usr/share/nginx/html/extra.js else echo "analytics enabled, injecting script" - echo "$INJECT_ANALYTICS" > /usr/share/nginx/html/extra.js + echo "$INJECT_EXTRA" >/usr/share/nginx/html/extra.js fi mkdir -p /etc/nginx/resolvers/ -echo resolver $(grep -oP '(?<=nameserver\s)[^\s]+' /etc/resolv.conf | awk '{ if ($1 ~ /:/) { printf "[" $1 "] "; } else { printf $1 " "; } }') valid=10s ipv6=off";" > /etc/nginx/resolvers/resolvers.conf +echo resolver $(grep -oP '(?<=nameserver\s)[^\s]+' /etc/resolv.conf | awk '{ if ($1 ~ /:/) { printf "[" $1 "] "; } else { printf $1 " "; } }') valid=10s ipv6=off";" >/etc/nginx/resolvers/resolvers.conf cat /etc/nginx/resolvers/resolvers.conf diff --git a/frontend/config/define.js b/frontend/config/define.js new file mode 100644 index 0000000000..efe09eda5b --- /dev/null +++ b/frontend/config/define.js @@ -0,0 +1,28 @@ +/** + * Global constants to make available to build + * + * @TODO Consolidate webpack and web-test-runner esbuild configs + */ +const path = require("path"); + +const isDevServer = process.env.WEBPACK_SERVE; + +const dotEnvPath = path.resolve( + process.cwd(), + `.env${isDevServer ? `.local` : ""}`, +); +require("dotenv").config({ + path: dotEnvPath, +}); + +const WEBSOCKET_HOST = + isDevServer && process.env.API_BASE_URL + ? new URL(process.env.API_BASE_URL).host + : process.env.WEBSOCKET_HOST || ""; + +module.exports = { + "window.process.env.WEBSOCKET_HOST": JSON.stringify(WEBSOCKET_HOST), + "window.process.env.ANALYTICS_NAMESPACE": JSON.stringify( + process.env.ANALYTICS_NAMESPACE || "", + ), +}; diff --git a/frontend/docs/docs/deploy/customization.md b/frontend/docs/docs/deploy/customization.md index 54caa35cd0..199cadf6e4 100644 --- a/frontend/docs/docs/deploy/customization.md +++ b/frontend/docs/docs/deploy/customization.md @@ -208,16 +208,22 @@ Browsertrix has the ability to cryptographically sign WACZ files with [Authsign] You can enable sign-ups by setting `registration_enabled` to `"1"`. Once enabled, your users can register by visiting `/sign-up`. -## Analytics +## Inject Extra JavaScript -You can add a script to inject any sort of analytics into the frontend by setting `inject_analytics` to the script. If present, it will be injected as a blocking script tag into every page — so we recommend you create the script tags that handle your analytics from within this script. +You can add a script to inject analytics, bug reporting tools, etc. into the frontend by setting `inject_extra` to script contents of your choosing. If present, it will be injected as a blocking script tag that runs when the frontend web app is initialized. -For example, here's a script that adds Plausible Analytics tracking: +For example, enabling analytics and tracking might look like this: -```ts -const plausible = document.createElement("script"); -plausible.src = "https://plausible.io/js/script.js"; -plausible.defer = true; -plausible.dataset.domain = "app.browsertrix.com"; -document.head.appendChild(plausible); +```yaml +inject_extra: > + const analytics = document.createElement("script"); + analytics.src = "https://cdn.example.com/analytics.js"; + analytics.defer = true; + + document.head.appendChild(analytics); + + window.analytics = window.analytics + || function () { (window.analytics.q = window.analytics.q || []).push(arguments); }; ``` + +Note that the script will only run when the web app loads, i.e. the first time the app is loaded in the browser and on hard refresh. The script will not run again upon clicking a link in the web app. This shouldn't be an issue with most analytics libraries, which should listen for changes to [window history](https://developer.mozilla.org/en-US/docs/Web/API/History). If you have a custom script that needs to re-run when the frontend URL changes, you'll need to add an event listener for the [`popstate` event](https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event). diff --git a/frontend/sample.env.local b/frontend/sample.env.local index 74c36fce23..d5fb5c9396 100644 --- a/frontend/sample.env.local +++ b/frontend/sample.env.local @@ -3,3 +3,5 @@ DOCS_URL=https://docs.browsertrix.com/ E2E_USER_EMAIL= E2E_USER_PASSWORD= GLITCHTIP_DSN= +INJECT_EXTRA= +ANALYTICS_NAMESPACE= diff --git a/frontend/src/features/collections/share-collection.ts b/frontend/src/features/collections/share-collection.ts index d0fa0361f8..ac65d17a1a 100644 --- a/frontend/src/features/collections/share-collection.ts +++ b/frontend/src/features/collections/share-collection.ts @@ -20,11 +20,13 @@ import { SelectCollectionAccess } from "./select-collection-access"; import { BtrixElement } from "@/classes/BtrixElement"; import { ClipboardController } from "@/controllers/clipboard"; import { RouteNamespace } from "@/routes"; +import { AnalyticsTrackEvent } from "@/trackEvents"; import { CollectionAccess, type Collection, type PublicCollection, } from "@/types/collection"; +import { track } from "@/utils/analytics"; enum Tab { Link = "link", @@ -113,6 +115,13 @@ export class ShareCollection extends BtrixElement { ?disabled=${!this.shareLink} @click=${() => { void this.clipboardController.copy(this.shareLink); + + track(AnalyticsTrackEvent.CopyShareCollectionLink, { + org_slug: this.slug, + collection_id: this.collectionId, + collection_name: this.collection?.name, + logged_in: !!this.authState, + }); }} > { + track(AnalyticsTrackEvent.DownloadPublicCollection, { + org_slug: this.slug, + collection_id: this.collectionId, + collection_name: this.collection?.name, + }); + }} > ${msg("Download Collection")} diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 7d36d52fb6..3f310ae254 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -24,6 +24,7 @@ import "./styles.css"; import { OrgTab, RouteNamespace, ROUTES } from "./routes"; import type { UserInfo, UserOrg } from "./types/user"; +import { pageView, type AnalyticsTrackProps } from "./utils/analytics"; import APIRouter, { type ViewState } from "./utils/APIRouter"; import AuthService, { type AuthEventDetail, @@ -158,6 +159,10 @@ export class App extends BtrixElement { ); } + firstUpdated() { + this.trackPageView(); + } + willUpdate(changedProperties: Map) { if (changedProperties.has("settings")) { AppStateService.updateSettings(this.settings || null); @@ -296,6 +301,22 @@ export class App extends BtrixElement { } else { window.history.pushState(this.viewState, "", urlStr); } + + this.trackPageView(); + } + + trackPageView() { + const { slug, collectionId } = this.viewState.params; + const pageViewProps: AnalyticsTrackProps = { + org_slug: slug || null, + logged_in: !!this.authState, + }; + + if (collectionId) { + pageViewProps.collection_id = collectionId; + } + + pageView(pageViewProps); } render() { diff --git a/frontend/src/trackEvents.ts b/frontend/src/trackEvents.ts new file mode 100644 index 0000000000..772252604d --- /dev/null +++ b/frontend/src/trackEvents.ts @@ -0,0 +1,9 @@ +/** + * All available analytics tracking events + */ + +export enum AnalyticsTrackEvent { + PageView = "pageview", + CopyShareCollectionLink = "Copy share collection link", + DownloadPublicCollection = "Download public collection", +} diff --git a/frontend/src/utils/analytics.ts b/frontend/src/utils/analytics.ts new file mode 100644 index 0000000000..1ca0568b1e --- /dev/null +++ b/frontend/src/utils/analytics.ts @@ -0,0 +1,40 @@ +/** + * Custom tracking for analytics. + * + * Any third-party analytics script will need to have been made + * available through the `extra.js` injected by the server. + */ + +import { AnalyticsTrackEvent } from "../trackEvents"; + +export type AnalyticsTrackProps = { + org_slug: string | null; + collection_id?: string | null; + collection_name?: string | null; + logged_in?: boolean; +}; + +export function track( + event: `${AnalyticsTrackEvent}`, + props?: AnalyticsTrackProps, +) { + // ANALYTICS_NAMESPACE is specified with webpack `DefinePlugin` + const analytics = window.process.env.ANALYTICS_NAMESPACE + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any)[window.process.env.ANALYTICS_NAMESPACE] + : null; + + if (!analytics) { + return; + } + + try { + analytics(event, { props }); + } catch (err) { + console.debug(err); + } +} + +export function pageView(props?: AnalyticsTrackProps) { + track(AnalyticsTrackEvent.PageView, props); +} diff --git a/frontend/web-test-runner.config.mjs b/frontend/web-test-runner.config.mjs index e8f2aa7f9e..bb82ab27bd 100644 --- a/frontend/web-test-runner.config.mjs +++ b/frontend/web-test-runner.config.mjs @@ -9,6 +9,8 @@ import { playwrightLauncher } from "@web/test-runner-playwright"; import glob from "glob"; import { typescriptPaths as typescriptPathsPlugin } from "rollup-plugin-typescript-paths"; +import defineConfig from "./config/define.js"; + const commonjs = fromRollup(commonjsPlugin); const typescriptPaths = fromRollup(typescriptPathsPlugin); @@ -55,6 +57,7 @@ export default { ts: true, tsconfig: fileURLToPath(new URL("./tsconfig.json", import.meta.url)), target: "esnext", + define: defineConfig, }), commonjs({ include: [ diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 93fd63b6cc..e64796614a 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -11,6 +11,7 @@ const HtmlWebpackPlugin = require("html-webpack-plugin"); const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); const webpack = require("webpack"); +const defineConfig = require("./config/define.js"); // @ts-ignore const packageJSON = require("./package.json"); @@ -24,11 +25,6 @@ require("dotenv").config({ path: dotEnvPath, }); -const WEBSOCKET_HOST = - isDevServer && process.env.API_BASE_URL - ? new URL(process.env.API_BASE_URL).host - : process.env.WEBSOCKET_HOST || ""; - const DOCS_URL = process.env.DOCS_URL ? new URL(process.env.DOCS_URL) : isDevServer @@ -164,9 +160,7 @@ const main = { ), }), - new webpack.DefinePlugin({ - "window.process.env.WEBSOCKET_HOST": JSON.stringify(WEBSOCKET_HOST), - }), + new webpack.DefinePlugin(defineConfig), new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 12, diff --git a/frontend/webpack.dev.js b/frontend/webpack.dev.js index 92f403181e..6c769aca90 100644 --- a/frontend/webpack.dev.js +++ b/frontend/webpack.dev.js @@ -76,10 +76,10 @@ module.exports = [ res.status(404).send(`{"error": "placeholder_for_replay"}`); }); - // serve empty analytics script + // Serve analytics script, which is set in prod as an env variable by the Helm chart server.app?.get("/extra.js", (req, res) => { res.set("Content-Type", "application/javascript"); - res.status(200).send(""); + res.status(200).send(process.env.INJECT_EXTRA || ""); }); return middlewares;