From 5d4a59153af4f7eb5ab6d6ac488d13ec4c4c4b18 Mon Sep 17 00:00:00 2001 From: Eugene Fox Date: Mon, 5 May 2025 19:25:25 +0300 Subject: [PATCH] feat: ga4 analytics #117 --- .github/workflows/cd_pipeline.yml | 4 +++ .github/workflows/pr_pipeline.yml | 4 +++ .gitignore | 2 ++ app.config.ts | 24 +++++++++++++ entrypoints/background.ts | 7 ++-- entrypoints/options/main.tsx | 2 ++ .../sidepanel/components/EditDialog.tsx | 5 +++ .../collections/CollectionListView.tsx | 2 ++ .../collections/messages/CtaMessage.tsx | 7 +++- .../sidepanel/layouts/header/MoreButton.tsx | 4 +-- entrypoints/sidepanel/main.tsx | 1 + eslint.config.js | 3 +- features/analytics/index.ts | 2 ++ features/analytics/utils/trackError.ts | 8 +++++ .../analytics/utils/userPropertiesStorage.ts | 35 +++++++++++++++++++ .../collectionStorage/utils/getCollections.ts | 2 ++ .../utils/resolveConflict.ts | 2 ++ .../utils/saveCollections.ts | 2 ++ .../v3welcome/components/WelcomeDialog.tsx | 5 ++- package.json | 3 +- utils/messaging.ts | 2 ++ utils/saveTabsToCollection.ts | 2 ++ utils/sendNotification.ts | 4 ++- wxt.config.ts | 2 +- yarn.lock | 12 +++++++ 25 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 app.config.ts create mode 100644 features/analytics/index.ts create mode 100644 features/analytics/utils/trackError.ts create mode 100644 features/analytics/utils/userPropertiesStorage.ts diff --git a/.github/workflows/cd_pipeline.yml b/.github/workflows/cd_pipeline.yml index bbc140a..d291f85 100644 --- a/.github/workflows/cd_pipeline.yml +++ b/.github/workflows/cd_pipeline.yml @@ -47,6 +47,10 @@ jobs: steps: - uses: actions/checkout@main + - run: | + echo "WXT_GA4_API_SECRET=${{ secrets.GA4_SECRET }}" >> .env + echo "WXT_GA4_MEASUREMENT_ID=${{ secrets.GA4_MEASUREMENT_ID }}" >> .env + - run: yarn install - run: yarn zip -b ${{ matrix.target }} diff --git a/.github/workflows/pr_pipeline.yml b/.github/workflows/pr_pipeline.yml index ba9c1ca..640f132 100644 --- a/.github/workflows/pr_pipeline.yml +++ b/.github/workflows/pr_pipeline.yml @@ -39,6 +39,10 @@ jobs: steps: - uses: actions/checkout@main + - run: | + echo "WXT_GA4_API_SECRET=${{ secrets.GA4_SECRET }}" >> .env + echo "WXT_GA4_MEASUREMENT_ID=${{ secrets.GA4_MEASUREMENT_ID }}" >> .env + - run: yarn install - run: yarn zip -b ${{ matrix.target }} diff --git a/.gitignore b/.gitignore index dc4e59f..f17a816 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ web-ext.config.ts *.sw? web-ext.config.js + +.env* diff --git a/app.config.ts b/app.config.ts new file mode 100644 index 0000000..62f49f1 --- /dev/null +++ b/app.config.ts @@ -0,0 +1,24 @@ +import { googleAnalytics4 } from "@wxt-dev/analytics/providers/google-analytics-4"; +import { WxtAppConfig } from "wxt/sandbox"; +import { userPropertiesStorage } from "./features/analytics"; + +export default defineAppConfig({ + analytics: + { + debug: import.meta.env.DEV, + enabled: storage.defineItem("local:analytics", { + fallback: true + }), + userId: storage.defineItem("local:userId", { + init: () => crypto.randomUUID() + }), + userProperties: userPropertiesStorage, + providers: + [ + googleAnalytics4({ + apiSecret: import.meta.env.WXT_GA4_API_SECRET, + measurementId: import.meta.env.WXT_GA4_MEASUREMENT_ID + }) + ] + } +} as WxtAppConfig); diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 675f879..266321f 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -1,3 +1,4 @@ +import { trackError } from "@/features/analytics"; import { collectionCount, getCollections, saveCollections } from "@/features/collectionStorage"; import { migrateStorage } from "@/features/migration"; import { showWelcomeDialog } from "@/features/v3welcome/utils/showWelcomeDialog"; @@ -30,6 +31,7 @@ export default defineBackground(() => browser.runtime.onInstalled.addListener(async ({ reason, previousVersion }) => { logger("onInstalled", reason, previousVersion); + analytics.track("extension_installed", { reason, previousVersion: previousVersion ?? "none" }); const previousMajor: number = previousVersion ? parseInt(previousVersion.split(".")[0]) : 0; @@ -88,11 +90,9 @@ export default defineBackground(() => preview: graphicsCache[tab.url]?.preview, icon: graphicsCache[tab.url]?.icon }; - - logger("Captured tab", tab.url); } } - catch (ex) { logger(ex); } + catch { } }; setInterval(() => @@ -374,5 +374,6 @@ export default defineBackground(() => catch (ex) { console.error(ex); + trackError("background_error", ex as Error); } }); diff --git a/entrypoints/options/main.tsx b/entrypoints/options/main.tsx index 8e70ff6..9c34224 100644 --- a/entrypoints/options/main.tsx +++ b/entrypoints/options/main.tsx @@ -14,6 +14,8 @@ ReactDOM.createRoot(document.getElementById("root")!).render( ); +analytics.page("options_page"); + function OptionsPage(): React.ReactElement { const [selection, setSelection] = useState("general"); diff --git a/entrypoints/sidepanel/components/EditDialog.tsx b/entrypoints/sidepanel/components/EditDialog.tsx index 86a99b3..ba28478 100644 --- a/entrypoints/sidepanel/components/EditDialog.tsx +++ b/entrypoints/sidepanel/components/EditDialog.tsx @@ -26,6 +26,11 @@ export default function EditDialog(props: GroupEditDialogProps): ReactElement const handleSave = () => { + if (props.type === "collection" ? props.collection !== null : props.group !== null) + analytics.track("item_edited", { type: props.type }); + else + analytics.track("item_created", { type: props.type }); + if (props.type === "collection") props.onSave({ type: "collection", diff --git a/entrypoints/sidepanel/layouts/collections/CollectionListView.tsx b/entrypoints/sidepanel/layouts/collections/CollectionListView.tsx index 1ec1889..4471c4c 100644 --- a/entrypoints/sidepanel/layouts/collections/CollectionListView.tsx +++ b/entrypoints/sidepanel/layouts/collections/CollectionListView.tsx @@ -64,6 +64,8 @@ export default function CollectionListView(): ReactElement updateCollections(result); if (sortMode !== "custom") setSortMode("custom"); + + analytics.track("used_drag_and_drop"); } }; diff --git a/entrypoints/sidepanel/layouts/collections/messages/CtaMessage.tsx b/entrypoints/sidepanel/layouts/collections/messages/CtaMessage.tsx index 847f065..0afb3fd 100644 --- a/entrypoints/sidepanel/layouts/collections/messages/CtaMessage.tsx +++ b/entrypoints/sidepanel/layouts/collections/messages/CtaMessage.tsx @@ -27,6 +27,11 @@ export default function CtaMessage(props: MessageBarProps): ReactElement { await ctaCounter.setValue(counter); setCounter(counter); + + if (counter === -1) + analytics.track("bmc_clicked"); + else + analytics.track("cta_dismissed"); }; if (counter < 50) @@ -36,7 +41,7 @@ export default function CtaMessage(props: MessageBarProps): ReactElement } { ...props }> { i18n.t("cta_message.title") } - { i18n.t("cta_message.message") } { i18n.t("cta_message.feedback") } + { i18n.t("cta_message.message") } analytics.track("feedback_clicked") }>{ i18n.t("cta_message.feedback") } - } { ...extLink(buyMeACoffeeLink) }> + } { ...extLink(buyMeACoffeeLink) } onClick={ () => analytics.track("feedback_clicked") }> { i18n.t("common.cta.sponsor") } - } { ...extLink(storeLink) } > + } { ...extLink(storeLink) } onClick={ () => analytics.track("bmc_clicked") }> { i18n.t("common.cta.feedback") } } { ...extLink(githubLinks.release) } > diff --git a/entrypoints/sidepanel/main.tsx b/entrypoints/sidepanel/main.tsx index a9a2560..55c3d39 100644 --- a/entrypoints/sidepanel/main.tsx +++ b/entrypoints/sidepanel/main.tsx @@ -15,6 +15,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render( ); document.title = i18n.t("manifest.name"); +analytics.page("collection_list"); function MainPage(): React.ReactElement { diff --git a/eslint.config.js b/eslint.config.js index 835d941..5f74e6f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -88,7 +88,8 @@ export default defineConfig([ "@typescript-eslint/no-unused-vars": ["warn"], "prefer-const": ["warn"], "@stylistic/padded-blocks": ["warn"], - "no-empty": ["off"] + "no-empty": ["off"], + "@stylistic/eol-last": ["warn"] } }, { diff --git a/features/analytics/index.ts b/features/analytics/index.ts new file mode 100644 index 0000000..9bb71e6 --- /dev/null +++ b/features/analytics/index.ts @@ -0,0 +1,2 @@ +export { default as userPropertiesStorage } from "./utils/userPropertiesStorage"; +export { default as trackError } from "./utils/trackError"; diff --git a/features/analytics/utils/trackError.ts b/features/analytics/utils/trackError.ts new file mode 100644 index 0000000..840a291 --- /dev/null +++ b/features/analytics/utils/trackError.ts @@ -0,0 +1,8 @@ +export default function trackError(name: string, error: Error): void +{ + analytics.track(name, { + name: error.name, + message: error.message, + stack: error.stack ?? "no_stack" + }); +} diff --git a/features/analytics/utils/userPropertiesStorage.ts b/features/analytics/utils/userPropertiesStorage.ts new file mode 100644 index 0000000..3b57010 --- /dev/null +++ b/features/analytics/utils/userPropertiesStorage.ts @@ -0,0 +1,35 @@ +import { cloudDisabled, collectionCount } from "@/features/collectionStorage"; +import { settings } from "@/utils/settings"; +import { WxtStorageItem } from "wxt/storage"; + +// @ts-expect-error we don't need to implement a full storage item +const userPropertiesStorage: WxtStorageItem, any> = +{ + getValue: async (): Promise => + { + console.log("userPropertiesStorage.getValue"); + const properties: UserProperties = + { + cloud_used: await cloudDisabled.getValue() ? "-1" : (await browser.storage.sync.getBytesInUse() / 102400).toString(), + collection_count: (await collectionCount.getValue()).toString() + }; + + for (const key of Object.keys(settings)) + { + const value = await settings[key as keyof typeof settings].getValue(); + properties[`option_${key}`] = value.valueOf().toString(); + } + + return properties; + }, + setValue: async () => { } +}; + +export default userPropertiesStorage; + +export type UserProperties = + { + collection_count: string; + cloud_used: string; + [key: `option_${string}`]: string; + }; diff --git a/features/collectionStorage/utils/getCollections.ts b/features/collectionStorage/utils/getCollections.ts index a428378..96b67e2 100644 --- a/features/collectionStorage/utils/getCollections.ts +++ b/features/collectionStorage/utils/getCollections.ts @@ -1,3 +1,4 @@ +import { trackError } from "@/features/analytics"; import { CollectionItem } from "@/models/CollectionModels"; import getLogger from "@/utils/getLogger"; import { collectionStorage } from "./collectionStorage"; @@ -32,6 +33,7 @@ export default async function getCollections(): Promise<[CollectionItem[], Cloud { logger("Failed to get cloud storage"); console.error(ex); + trackError("cloud_get_error", ex as Error); return [await getCollectionsFromLocal(), "parse_error"]; } } diff --git a/features/collectionStorage/utils/resolveConflict.ts b/features/collectionStorage/utils/resolveConflict.ts index 2b0e463..d0d920f 100644 --- a/features/collectionStorage/utils/resolveConflict.ts +++ b/features/collectionStorage/utils/resolveConflict.ts @@ -1,3 +1,4 @@ +import { trackError } from "@/features/analytics"; import { CollectionItem } from "@/models/CollectionModels"; import getLogger from "@/utils/getLogger"; import { collectionStorage } from "./collectionStorage"; @@ -37,5 +38,6 @@ async function replaceLocalWithCloud(): Promise { logger("Failed to get cloud storage"); console.error(ex); + trackError("conflict_resolve_with_cloud_error", ex as Error); } } diff --git a/features/collectionStorage/utils/saveCollections.ts b/features/collectionStorage/utils/saveCollections.ts index 4b312c6..857ee7a 100644 --- a/features/collectionStorage/utils/saveCollections.ts +++ b/features/collectionStorage/utils/saveCollections.ts @@ -1,3 +1,4 @@ +import { trackError } from "@/features/analytics"; import { CollectionItem, GraphicsStorage } from "@/models/CollectionModels"; import getLogger from "@/utils/getLogger"; import sendNotification from "@/utils/sendNotification"; @@ -26,6 +27,7 @@ export default async function saveCollections( { logger("Failed to save cloud storage"); console.error(ex); + trackError("cloud_save_error", ex as Error); if ((ex as Error).message.includes("MAX_WRITE_OPERATIONS_PER_MINUTE")) await sendNotification({ diff --git a/features/v3welcome/components/WelcomeDialog.tsx b/features/v3welcome/components/WelcomeDialog.tsx index 391b9cf..3085564 100644 --- a/features/v3welcome/components/WelcomeDialog.tsx +++ b/features/v3welcome/components/WelcomeDialog.tsx @@ -39,7 +39,10 @@ export default function WelcomeDialog(): React.ReactElement - + analytics.track("visit_blog_button_click") } + > { i18n.t("features.v3welcome.actions.visit_blog") } diff --git a/package.json b/package.json index 23f1b61..e478208 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tabs-aside", "private": true, - "version": "3.0.0-rc1", + "version": "3.0.0-rc2", "type": "module", "scripts": { "dev": "wxt", @@ -20,6 +20,7 @@ "@fluentui/react-components": "^9.63.0", "@fluentui/react-icons": "^2.0.298", "@webext-core/messaging": "^2.2.0", + "@wxt-dev/analytics": "^0.4.1", "@wxt-dev/i18n": "^0.2.3", "lzutf8": "^0.6.3", "react": "^18.3.1", diff --git a/utils/messaging.ts b/utils/messaging.ts index d7dfbdf..d0c7c99 100644 --- a/utils/messaging.ts +++ b/utils/messaging.ts @@ -1,3 +1,4 @@ +import { trackError } from "@/features/analytics"; import { GraphicsStorage } from "@/models/CollectionModels"; import { defineExtensionMessaging, ExtensionMessenger } from "@webext-core/messaging"; @@ -21,6 +22,7 @@ export const sendMessage: ExtensionMessenger["sendMessage"] = async catch (ex) { console.error(ex); + trackError("messaging_error", ex as Error); return undefined!; } }; diff --git a/utils/saveTabsToCollection.ts b/utils/saveTabsToCollection.ts index c1e4209..e155cdd 100644 --- a/utils/saveTabsToCollection.ts +++ b/utils/saveTabsToCollection.ts @@ -24,6 +24,8 @@ export default async function saveTabsToCollection(closeTabs: boolean): Promise< if (closeTabs) await browser.tabs.remove(tabsToClose.map(i => i.id!)); + analytics.track(closeTabs ? "set_aside" : "save"); + return collection; } diff --git a/utils/sendNotification.ts b/utils/sendNotification.ts index 40d35dc..f63f809 100644 --- a/utils/sendNotification.ts +++ b/utils/sendNotification.ts @@ -1,3 +1,4 @@ +import { trackError } from "@/features/analytics"; import { PublicPath } from "wxt/browser"; export default async function sendNotification(props: NotificationProps): Promise @@ -13,8 +14,9 @@ export default async function sendNotification(props: NotificationProps): Promis } catch (ex) { - console.error("Error while showing cloud error notification (probably because of user restrictions)"); + console.error("Error while showing notification (probably because of user restrictions)"); console.error(ex); + trackError("notification_error", ex as Error); } } diff --git a/wxt.config.ts b/wxt.config.ts index d1c752c..dfeee6d 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -2,7 +2,7 @@ import { ConfigEnv, defineConfig, UserManifest } from "wxt"; // See https://wxt.dev/api/config.html export default defineConfig({ - modules: ["@wxt-dev/module-react", "@wxt-dev/i18n/module"], + modules: ["@wxt-dev/module-react", "@wxt-dev/i18n/module", "@wxt-dev/analytics/module"], imports: { dirsScanOptions: { diff --git a/yarn.lock b/yarn.lock index 81296f7..2af045c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2031,6 +2031,13 @@ serialize-error "^11.0.0" webextension-polyfill "^0.10.0" +"@wxt-dev/analytics@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@wxt-dev/analytics/-/analytics-0.4.1.tgz#23ecbff34eec04690e64f8f9850832baa207486c" + integrity sha512-BDRyfIxO7MKoXLM2jCxX+EVCDjB5jjWGM2GWlU0mYQwi+IzSwMUHnw0UMnDeQ1Zr6yiyjopgCdg4XGaV9QsLJg== + dependencies: + ua-parser-js "^1.0.38" + "@wxt-dev/i18n@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@wxt-dev/i18n/-/i18n-0.2.3.tgz#5688cbdf5324e86fbd65d585073121bf8d493085" @@ -6398,6 +6405,11 @@ typescript@^5.8.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== +ua-parser-js@^1.0.38: + version "1.0.40" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.40.tgz#ac6aff4fd8ea3e794a6aa743ec9c2fc29e75b675" + integrity sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew== + ufo@^1.5.4: version "1.6.1" resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.1.tgz#ac2db1d54614d1b22c1d603e3aef44a85d8f146b"