diff --git a/package.json b/package.json index 033a6fc85..8ed154dce 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "jotai-family": "^1.0.1", "js-sha256": "^0.11.1", "ollama-ai-provider-v2": "^3.4.0", + "posthog-js": "^1.360.2", "react": "^19.2.4", "react-dom": "^19.2.4", "react-error-boundary": "^6.1.1", @@ -154,7 +155,7 @@ "type-fest": "^5.4.4", "typescript": "^5.9.3", "vitest": "^4.1.0", - "wxt": "0.20.18" + "wxt": "0.20.19" }, "devEngines": { "runtime": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b10848a09..76d470e07 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,7 +163,7 @@ importers: version: 2.3.0 '@wxt-dev/i18n': specifier: ^0.2.5 - version: 0.2.5(wxt@0.20.18(@types/node@20.19.25)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.53.2)(yaml@2.8.2)) + version: 0.2.5(wxt@0.20.19(@types/node@20.19.25)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.53.2)(yaml@2.8.2)) ai: specifier: ^6.0.116 version: 6.0.116(zod@4.3.6) @@ -221,6 +221,9 @@ importers: ollama-ai-provider-v2: specifier: ^3.4.0 version: 3.4.0(ai@6.0.116(zod@4.3.6))(zod@4.3.6) + posthog-js: + specifier: ^1.360.2 + version: 1.360.2 react: specifier: ^19.2.4 version: 19.2.4 @@ -323,7 +326,7 @@ importers: version: 4.1.0(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@20.19.25)(jsdom@28.1.0(@noble/hashes@2.0.1))(msw@2.12.10(@types/node@20.19.25)(typescript@5.9.3))(vite@7.2.2(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))) '@wxt-dev/module-react': specifier: ^1.2.2 - version: 1.2.2(vite@7.2.2(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))(wxt@0.20.18(@types/node@20.19.25)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.53.2)(yaml@2.8.2)) + version: 1.2.2(vite@7.2.2(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))(wxt@0.20.19(@types/node@20.19.25)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.53.2)(yaml@2.8.2)) autoprefixer: specifier: ^10.4.27 version: 10.4.27(postcss@8.5.8) @@ -379,8 +382,8 @@ importers: specifier: ^4.1.0 version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@20.19.25)(jsdom@28.1.0(@noble/hashes@2.0.1))(msw@2.12.10(@types/node@20.19.25)(typescript@5.9.3))(vite@7.2.2(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) wxt: - specifier: 0.20.18 - version: 0.20.18(@types/node@20.19.25)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.53.2)(yaml@2.8.2) + specifier: 0.20.19 + version: 0.20.19(@types/node@20.19.25)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.53.2)(yaml@2.8.2) packages: @@ -1807,14 +1810,6 @@ packages: '@types/node': optional: true - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.1': - resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==} - engines: {node: 20 || >=22} - '@istanbuljs/schema@0.1.3': resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} @@ -2014,10 +2009,78 @@ packages: ai: ^6.0.0 zod: ^3.25.0 || ^4.0.0 + '@opentelemetry/api-logs@0.208.0': + resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@opentelemetry/core@2.2.0': + resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.6.0': + resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-http@0.208.0': + resolution: {integrity: sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.208.0': + resolution: {integrity: sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.208.0': + resolution: {integrity: sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.2.0': + resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.6.0': + resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.208.0': + resolution: {integrity: sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.2.0': + resolution: {integrity: sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.2.0': + resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.40.0': + resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} + engines: {node: '>=14'} + '@orpc/client@1.13.4': resolution: {integrity: sha512-s13GPMeoooJc5Th2EaYT5HMFtWG8S03DUVytYfJv8pIhP87RYKl94w52A36denH6r/B4LaAgBeC9nTAOslK+Og==} @@ -2203,6 +2266,42 @@ packages: resolution: {integrity: sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==} engines: {node: '>=12'} + '@posthog/core@1.23.4': + resolution: {integrity: sha512-gSM1gnIuw5UOBUOTz0IhCTH8jOHoFr5rzSDb5m7fn9ofLHvz3boZT1L1f+bcuk+mvzNJfrJ3ByVQGKmUQnKQ8g==} + + '@posthog/types@1.360.2': + resolution: {integrity: sha512-U48CbtmX5kETZvWjaJVlublSA1aLV99m71TQtgxWksBMXINS/3C7j+KqlMO6wH7SuaEZQnjaxh1KYGH4nRCaaA==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -2973,6 +3072,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -3779,10 +3881,6 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} - cli-spinners@3.4.0: - resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} - engines: {node: '>=18.20'} - cli-truncate@4.0.0: resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} engines: {node: '>=18'} @@ -3951,6 +4049,9 @@ packages: core-js-compat@3.46.0: resolution: {integrity: sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==} + core-js@3.48.0: + resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -4184,6 +4285,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.3.3: + resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -4215,6 +4319,10 @@ packages: resolution: {integrity: sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==} engines: {node: '>=12'} + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + engines: {node: '>=12'} + dotenv@8.6.0: resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} engines: {node: '>=10'} @@ -4724,6 +4832,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fflate@0.4.8: + resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -5330,6 +5441,10 @@ packages: resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} engines: {node: '>=16'} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + isarray@0.0.1: resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} @@ -5667,14 +5782,13 @@ packages: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} - log-symbols@7.0.1: - resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} - engines: {node: '>=18'} - log-update@6.1.0: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -5914,10 +6028,6 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} - minimatch@10.1.2: - resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} - engines: {node: 20 || >=22} - minimatch@10.2.2: resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} engines: {node: 18 || 20 || >=22} @@ -6016,6 +6126,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanospinner@1.2.2: + resolution: {integrity: sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==} + nanostores@1.2.0: resolution: {integrity: sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==} engines: {node: ^20.0.0 || >=22.0.0} @@ -6260,10 +6373,6 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} - ora@9.3.0: - resolution: {integrity: sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==} - engines: {node: '>=20'} - os-shim@0.1.3: resolution: {integrity: sha512-jd0cvB8qQ5uVt0lvCIexBaROw1KyKm5sbulg2fWOHjETisuCzWyt+eTZKEMs8v6HwzoGs8xik26jg7eCM6pS+A==} engines: {node: '>= 0.4.0'} @@ -6476,10 +6585,16 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + posthog-js@1.360.2: + resolution: {integrity: sha512-/Wed0mOuRUfyEGT/BRQaokCqBlxrEceE7MDT9A00lU5tXo443/2Pg9ZiqN5sucUluZF47hwGORpYPoVUt32UFw==} + powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + preact@10.29.0: + resolution: {integrity: sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -6533,6 +6648,10 @@ packages: proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} + protocol-buffers-schema@3.6.0: resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==} @@ -6565,6 +6684,9 @@ packages: quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + query-selector-shadow-dom@1.0.1: + resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -7034,10 +7156,6 @@ packages: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} - stdin-discarder@0.3.1: - resolution: {integrity: sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==} - engines: {node: '>=18'} - stream-source@0.3.5: resolution: {integrity: sha512-ZuEDP9sgjiAwUVoDModftG0JtYiLUV8K4ljYD1VyUMRWtbVf92474o4kuuul43iZ8t/hRuiDAx1dIJSvirrK/g==} @@ -7606,6 +7724,9 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + web-vitals@5.1.0: + resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==} + webextension-polyfill@0.10.0: resolution: {integrity: sha512-c5s35LgVa5tFaHhrZDnr3FpQpjj1BB+RXhLTYUxGqBVN460HkbM8TBtEqdXWbpTKfzwCcjAZVF7zXCYSKtcp9g==} @@ -7693,11 +7814,11 @@ packages: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} - wxt@0.20.18: - resolution: {integrity: sha512-BYnIAFkdJcC8BXzbh4PzmRhOQ5xKELEk45qntzqojW5X1+VGm0GsjaEKSCQnTP72/3jZMDH1pmlEdkY/fPXehg==} + wxt@0.20.19: + resolution: {integrity: sha512-LNQXDyStuenNSLLbSs3aXDscKB6g6NYUXppBu7uAmIUZNKLy04Hyg3EE9p9w683t0B+j2CBYciDmqglfwisNuA==} hasBin: true peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: eslint: optional: true @@ -9357,12 +9478,6 @@ snapshots: optionalDependencies: '@types/node': 20.19.25 - '@isaacs/balanced-match@4.0.1': {} - - '@isaacs/brace-expansion@5.0.1': - dependencies: - '@isaacs/balanced-match': 4.0.1 - '@istanbuljs/schema@0.1.3': {} '@jest/diff-sequences@30.0.1': {} @@ -9558,8 +9673,82 @@ snapshots: ai: 6.0.116(zod@4.3.6) zod: 4.3.6 + '@opentelemetry/api-logs@0.208.0': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api@1.9.0': {} + '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/exporter-logs-otlp-http@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-exporter-base@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) + protobufjs: 7.5.4 + + '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/semantic-conventions@1.40.0': {} + '@orpc/client@1.13.4(@opentelemetry/api@1.9.0)': dependencies: '@orpc/shared': 1.13.4(@opentelemetry/api@1.9.0) @@ -9714,6 +9903,35 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 + '@posthog/core@1.23.4': + dependencies: + cross-spawn: 7.0.6 + + '@posthog/types@1.360.2': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@radix-ui/primitive@1.1.3': {} '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': @@ -10371,6 +10589,9 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -10890,20 +11111,20 @@ snapshots: '@types/filesystem': 0.0.36 '@types/har-format': 1.2.16 - '@wxt-dev/i18n@0.2.5(wxt@0.20.18(@types/node@20.19.25)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.53.2)(yaml@2.8.2))': + '@wxt-dev/i18n@0.2.5(wxt@0.20.19(@types/node@20.19.25)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.53.2)(yaml@2.8.2))': dependencies: '@wxt-dev/browser': 0.1.37 chokidar: 5.0.0 confbox: 0.2.2 fast-glob: 3.3.3 optionalDependencies: - wxt: 0.20.18(@types/node@20.19.25)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.53.2)(yaml@2.8.2) + wxt: 0.20.19(@types/node@20.19.25)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.53.2)(yaml@2.8.2) - '@wxt-dev/module-react@1.2.2(vite@7.2.2(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))(wxt@0.20.18(@types/node@20.19.25)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.53.2)(yaml@2.8.2))': + '@wxt-dev/module-react@1.2.2(vite@7.2.2(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2))(wxt@0.20.19(@types/node@20.19.25)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.53.2)(yaml@2.8.2))': dependencies: '@vitejs/plugin-react': 5.2.0(vite@7.2.2(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) vite: 7.2.2(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) - wxt: 0.20.18(@types/node@20.19.25)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.53.2)(yaml@2.8.2) + wxt: 0.20.19(@types/node@20.19.25)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.53.2)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -11207,7 +11428,7 @@ snapshots: chokidar: 5.0.0 confbox: 0.2.2 defu: 6.1.4 - dotenv: 17.2.4 + dotenv: 17.3.1 exsolve: 1.0.8 giget: 2.0.0 jiti: 2.6.1 @@ -11311,8 +11532,6 @@ snapshots: cli-spinners@2.9.2: {} - cli-spinners@3.4.0: {} - cli-truncate@4.0.0: dependencies: slice-ansi: 5.0.0 @@ -11471,6 +11690,8 @@ snapshots: dependencies: browserslist: 4.28.1 + core-js@3.48.0: {} + core-util-is@1.0.3: {} cors@2.8.6: @@ -11657,6 +11878,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.3.3: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -11685,6 +11910,8 @@ snapshots: dotenv@17.2.4: {} + dotenv@17.3.1: {} + dotenv@8.6.0: {} dunder-proto@1.0.1: @@ -12404,6 +12631,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + fflate@0.4.8: {} + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -12942,6 +13171,10 @@ snapshots: dependencies: is-inside-container: 1.0.0 + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + isarray@0.0.1: {} isarray@1.0.0: {} @@ -13246,11 +13479,6 @@ snapshots: chalk: 5.6.2 is-unicode-supported: 1.3.0 - log-symbols@7.0.1: - dependencies: - is-unicode-supported: 2.1.0 - yoctocolors: 2.1.2 - log-update@6.1.0: dependencies: ansi-escapes: 7.2.0 @@ -13259,6 +13487,8 @@ snapshots: strip-ansi: 7.1.2 wrap-ansi: 9.0.2 + long@5.3.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -13698,10 +13928,6 @@ snapshots: min-indent@1.0.1: {} - minimatch@10.1.2: - dependencies: - '@isaacs/brace-expansion': 5.0.1 - minimatch@10.2.2: dependencies: brace-expansion: 5.0.3 @@ -13788,6 +14014,10 @@ snapshots: nanoid@3.3.11: {} + nanospinner@1.2.2: + dependencies: + picocolors: 1.1.1 + nanostores@1.2.0: {} natural-compare@1.4.0: {} @@ -13818,7 +14048,7 @@ snapshots: dependencies: growly: 1.3.0 is-wsl: 2.2.0 - semver: 7.7.3 + semver: 7.7.4 shellwords: 0.1.1 uuid: 8.3.2 which: 2.0.2 @@ -13898,7 +14128,7 @@ snapshots: dependencies: citty: 0.2.0 pathe: 2.0.3 - tinyexec: 1.0.2 + tinyexec: 1.0.4 object-assign@4.1.1: {} @@ -13992,17 +14222,6 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.2 - ora@9.3.0: - dependencies: - chalk: 5.6.2 - cli-cursor: 5.0.0 - cli-spinners: 3.4.0 - is-interactive: 2.0.0 - is-unicode-supported: 2.1.0 - log-symbols: 7.0.1 - stdin-discarder: 0.3.1 - string-width: 8.1.0 - os-shim@0.1.3: {} outdent@0.5.0: {} @@ -14062,7 +14281,7 @@ snapshots: ky: 1.14.0 registry-auth-token: 5.1.0 registry-url: 6.0.1 - semver: 7.7.3 + semver: 7.7.4 package-manager-detector@0.2.11: dependencies: @@ -14237,8 +14456,26 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + posthog-js@1.360.2: + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + '@posthog/core': 1.23.4 + '@posthog/types': 1.360.2 + core-js: 3.48.0 + dompurify: 3.3.3 + fflate: 0.4.8 + preact: 10.29.0 + query-selector-shadow-dom: 1.0.1 + web-vitals: 5.1.0 + powershell-utils@0.1.0: {} + preact@10.29.0: {} + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.1: @@ -14288,6 +14525,21 @@ snapshots: proto-list@1.2.4: {} + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 20.19.25 + long: 5.3.2 + protocol-buffers-schema@3.6.0: {} proxy-addr@2.0.7: @@ -14301,7 +14553,7 @@ snapshots: dependencies: cac: 6.7.14 consola: 3.4.2 - dotenv: 17.2.4 + dotenv: 17.3.1 form-data-encoder: 4.1.0 formdata-node: 6.0.3 listr2: 8.3.3 @@ -14322,6 +14574,8 @@ snapshots: quansync@0.2.11: {} + query-selector-shadow-dom@1.0.1: {} + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} @@ -14875,8 +15129,6 @@ snapshots: stdin-discarder@0.2.2: {} - stdin-discarder@0.3.1: {} - stream-source@0.3.5: {} strict-event-emitter@0.5.1: {} @@ -15252,7 +15504,7 @@ snapshots: is-npm: 6.1.0 latest-version: 9.0.0 pupa: 3.3.0 - semver: 7.7.3 + semver: 7.7.4 xdg-basedir: 5.1.0 uri-js@4.4.1: @@ -15426,6 +15678,8 @@ snapshots: web-streams-polyfill@3.3.3: {} + web-vitals@5.1.0: {} + webextension-polyfill@0.10.0: {} webidl-conversions@3.0.1: {} @@ -15511,7 +15765,7 @@ snapshots: is-wsl: 3.1.0 powershell-utils: 0.1.0 - wxt@0.20.18(@types/node@20.19.25)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.53.2)(yaml@2.8.2): + wxt@0.20.19(@types/node@20.19.25)(eslint@9.39.3(jiti@2.6.1))(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.53.2)(yaml@2.8.2): dependencies: '@1natsu/wait-element': 4.1.2 '@aklinker1/rollup-plugin-visualizer': 5.12.0(rollup@4.53.2) @@ -15522,38 +15776,37 @@ snapshots: '@wxt-dev/storage': 1.2.6 async-mutex: 0.5.0 c12: 3.3.3(magicast@0.5.2) - cac: 6.7.14 + cac: 7.0.0 chokidar: 5.0.0 ci-info: 4.4.0 consola: 3.4.2 defu: 6.1.4 - dotenv: 17.2.4 + dotenv: 17.3.1 dotenv-expand: 12.0.3 esbuild: 0.27.2 - fast-glob: 3.3.3 filesize: 11.0.13 - fs-extra: 11.3.3 get-port-please: 3.2.0 giget: 2.0.0 hookable: 6.0.1 import-meta-resolve: 4.2.0 - is-wsl: 3.1.0 + is-wsl: 3.1.1 json5: 2.2.3 jszip: 3.10.1 linkedom: 0.18.12 magicast: 0.5.2 - minimatch: 10.1.2 + minimatch: 10.2.4 nano-spawn: 2.0.0 + nanospinner: 1.2.2 normalize-path: 3.0.0 nypm: 0.6.5 ohash: 2.0.11 open: 11.0.0 - ora: 9.3.0 perfect-debounce: 2.1.0 picocolors: 1.1.1 prompts: 2.4.2 publish-browser-extension: 3.0.3 scule: 1.3.0 + tinyglobby: 0.2.15 unimport: 5.5.0 vite: 7.2.2(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) vite-node: 3.2.4(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) diff --git a/src/entrypoints/background/__tests__/analytics.test.ts b/src/entrypoints/background/__tests__/analytics.test.ts new file mode 100644 index 000000000..a63d8dd07 --- /dev/null +++ b/src/entrypoints/background/__tests__/analytics.test.ts @@ -0,0 +1,251 @@ +import type { FeatureUsedEventProperties } from "@/types/analytics" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { createBackgroundAnalytics, filterAnalyticsCaptureResult } from "../analytics" + +type RegisteredMessageHandler = (message: { + data: FeatureUsedEventProperties +}) => Promise + +describe("background analytics", () => { + let onMessageMock: ReturnType + let storageGetItemMock: ReturnType + let storageSetItemMock: ReturnType + let posthogInitMock: ReturnType + let posthogCaptureMock: ReturnType + let posthogRegisterMock: ReturnType + let loggerWarnMock: ReturnType + + function getRegisteredMessageHandler(name: string) { + const registration = onMessageMock.mock.calls.find(call => call[0] === name) + if (!registration) { + throw new Error(`Message handler not registered: ${name}`) + } + + return registration[1] as RegisteredMessageHandler + } + + function createAnalytics(overrides?: { + apiHost?: string + apiKey?: string + defaultAnalyticsEnabled?: boolean + distinctIdOverride?: string + }) { + const apiHost = overrides && "apiHost" in overrides ? overrides.apiHost : "https://us.i.posthog.com" + const apiKey = overrides && "apiKey" in overrides ? overrides.apiKey : "phc_test" + + return createBackgroundAnalytics({ + apiHost, + apiKey, + createDistinctId: () => "generated-install-id", + defaultAnalyticsEnabled: overrides?.defaultAnalyticsEnabled ?? true, + distinctIdOverride: overrides?.distinctIdOverride, + extensionVersion: "1.0.0", + getStorageItem: storageGetItemMock as (key: string) => Promise, + onMessage: onMessageMock as (type: "trackFeatureUsedEvent", handler: RegisteredMessageHandler) => unknown, + posthog: { + init: posthogInitMock as (token: string, config: Record) => void, + capture: posthogCaptureMock as (eventName: string, properties: FeatureUsedEventProperties) => void, + register: posthogRegisterMock as (properties: { extension_version: string }) => void, + }, + setStorageItem: storageSetItemMock as (key: string, value: unknown) => Promise, + warn: loggerWarnMock as (...args: any[]) => void, + }) + } + + beforeEach(() => { + onMessageMock = vi.fn() + storageGetItemMock = vi.fn() + storageSetItemMock = vi.fn() + posthogInitMock = vi.fn() + posthogCaptureMock = vi.fn() + posthogRegisterMock = vi.fn() + loggerWarnMock = vi.fn() + }) + + it("registers a handler that initializes PostHog with the shared anonymous distinct ID", async () => { + storageGetItemMock + .mockResolvedValueOnce(true) + .mockResolvedValueOnce("install-123") + + const { setupAnalyticsMessageHandlers } = createAnalytics() + setupAnalyticsMessageHandlers() + + const handler = getRegisteredMessageHandler("trackFeatureUsedEvent") + await handler({ + data: { + feature: "page_translation", + surface: "popup", + outcome: "success", + latency_ms: 1_500, + }, + }) + + expect(posthogInitMock).toHaveBeenCalledWith( + "phc_test", + expect.objectContaining({ + api_host: "https://us.i.posthog.com", + autocapture: false, + before_send: expect.any(Function), + save_campaign_params: false, + save_referrer: false, + capture_pageview: false, + capture_pageleave: false, + disable_external_dependency_loading: true, + disable_session_recording: true, + advanced_disable_flags: true, + person_profiles: "never", + persistence: "memory", + respect_dnt: true, + bootstrap: { + distinctID: "install-123", + }, + }), + ) + expect(posthogRegisterMock).toHaveBeenCalledWith({ + extension_version: "1.0.0", + }) + expect(posthogCaptureMock).toHaveBeenCalledWith("feature_used", { + feature: "page_translation", + surface: "popup", + outcome: "success", + latency_ms: 1_500, + }) + expect(storageSetItemMock).not.toHaveBeenCalled() + }) + + it("does not initialize PostHog when analytics is disabled", async () => { + storageGetItemMock.mockResolvedValueOnce(false) + + const { captureFeatureUsedEventInBackground } = createAnalytics() + await captureFeatureUsedEventInBackground({ + feature: "page_translation", + surface: "popup", + outcome: "success", + latency_ms: 1_500, + }) + + expect(posthogInitMock).not.toHaveBeenCalled() + expect(posthogCaptureMock).not.toHaveBeenCalled() + }) + + it("uses the runtime default when the preference has not been stored yet", async () => { + storageGetItemMock.mockResolvedValueOnce(undefined) + + const { captureFeatureUsedEventInBackground } = createAnalytics({ + defaultAnalyticsEnabled: false, + }) + await captureFeatureUsedEventInBackground({ + feature: "page_translation", + surface: "popup", + outcome: "success", + latency_ms: 100, + }) + + expect(posthogInitMock).not.toHaveBeenCalled() + expect(posthogCaptureMock).not.toHaveBeenCalled() + }) + + it("creates and persists a new anonymous distinct ID when one does not exist", async () => { + storageGetItemMock + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(null) + + const { captureFeatureUsedEventInBackground } = createAnalytics() + await captureFeatureUsedEventInBackground({ + feature: "page_translation", + surface: "popup", + outcome: "success", + latency_ms: 100, + }) + + expect(storageSetItemMock).toHaveBeenCalledWith( + "local:analyticsInstallId", + "generated-install-id", + ) + }) + + it("uses the test UUID override without touching install ID storage", async () => { + storageGetItemMock.mockResolvedValueOnce(true) + + const { captureFeatureUsedEventInBackground } = createAnalytics({ + distinctIdOverride: "00000000-0000-0000-0000-000000000001", + }) + await captureFeatureUsedEventInBackground({ + feature: "page_translation", + surface: "popup", + outcome: "success", + latency_ms: 100, + }) + + expect(posthogInitMock).toHaveBeenCalledWith( + "phc_test", + expect.objectContaining({ + bootstrap: { + distinctID: "00000000-0000-0000-0000-000000000001", + }, + }), + ) + expect(storageSetItemMock).not.toHaveBeenCalled() + }) + + it("warns and no-ops when PostHog env configuration is missing", async () => { + storageGetItemMock.mockResolvedValueOnce(true) + + const { captureFeatureUsedEventInBackground } = createAnalytics({ + apiHost: undefined, + apiKey: undefined, + }) + await captureFeatureUsedEventInBackground({ + feature: "page_translation", + surface: "popup", + outcome: "failure", + latency_ms: 42, + }) + + expect(posthogInitMock).not.toHaveBeenCalled() + expect(posthogCaptureMock).not.toHaveBeenCalled() + expect(loggerWarnMock).toHaveBeenCalledOnce() + }) + + it("filters PostHog properties down to the allowlist", () => { + expect(filterAnalyticsCaptureResult({ + event: "feature_used", + properties: { + token: "phc_test", + distinct_id: "install-123", + feature: "page_translation", + surface: "popup", + outcome: "success", + latency_ms: 250, + $browser: "Chrome", + $browser_version: "145.0.0.0", + $insert_id: "insert-123", + $time: 1234, + $lib: "web", + $lib_version: "1.360.2", + $process_person_profile: false, + extension_version: "1.0.0", + $current_url: "chrome-extension://abc/background.js", + $raw_user_agent: "Mozilla/5.0", + $timezone: "America/Vancouver", + }, + timestamp: new Date("2026-03-16T19:02:43.960Z"), + uuid: "test-uuid", + }).properties).toEqual({ + token: "phc_test", + distinct_id: "install-123", + feature: "page_translation", + surface: "popup", + outcome: "success", + latency_ms: 250, + $browser: "Chrome", + $browser_version: "145.0.0.0", + $insert_id: "insert-123", + $time: 1234, + $lib: "web", + $lib_version: "1.360.2", + $process_person_profile: false, + extension_version: "1.0.0", + }) + }) +}) diff --git a/src/entrypoints/background/analytics.ts b/src/entrypoints/background/analytics.ts new file mode 100644 index 000000000..85ce39d29 --- /dev/null +++ b/src/entrypoints/background/analytics.ts @@ -0,0 +1,196 @@ +import type { CaptureResult } from "posthog-js/dist/module.no-external" +import type { FeatureUsedEventProperties } from "@/types/analytics" +import { storage } from "#imports" +import posthog from "posthog-js/dist/module.no-external" +import { + ANALYTICS_ENABLED_STORAGE_KEY, + ANALYTICS_FEATURE_USED_EVENT, + ANALYTICS_INSTALL_ID_STORAGE_KEY, + DEFAULT_ANALYTICS_ENABLED, +} from "@/utils/constants/analytics" +import { EXTENSION_VERSION } from "@/utils/constants/app" +import { logger } from "@/utils/logger" +import { onMessage } from "@/utils/message" + +interface BackgroundAnalyticsClient { + capture: (eventName: string, properties: FeatureUsedEventProperties) => void + init: (token: string, config: Record) => void + register: (properties: { extension_version: string }) => void +} + +interface BackgroundAnalyticsRuntime { + apiHost?: string + apiKey?: string + createDistinctId: () => string + defaultAnalyticsEnabled: boolean + distinctIdOverride?: string + extensionVersion: string + getStorageItem: (key: string) => Promise + onMessage: (type: "trackFeatureUsedEvent", handler: (message: { data: FeatureUsedEventProperties }) => Promise) => unknown + posthog: BackgroundAnalyticsClient + setStorageItem: (key: string, value: unknown) => Promise + warn: typeof logger.warn +} + +function createDefaultRuntime(): BackgroundAnalyticsRuntime { + return { + apiHost: import.meta.env.WXT_POSTHOG_HOST, + apiKey: import.meta.env.WXT_POSTHOG_API_KEY, + createDistinctId: () => crypto.randomUUID(), + defaultAnalyticsEnabled: DEFAULT_ANALYTICS_ENABLED, + distinctIdOverride: import.meta.env.WXT_POSTHOG_TEST_UUID, + extensionVersion: EXTENSION_VERSION, + getStorageItem: key => storage.getItem(key as `local:${string}`), + onMessage, + posthog, + setStorageItem: (key, value) => storage.setItem(key as `local:${string}`, value), + warn: logger.warn, + } +} + +type AnalyticsCaptureProperties = Record + +function setPropertyIfDefined( + properties: AnalyticsCaptureProperties, + key: string, + value: unknown, +): void { + if (value !== undefined) { + properties[key] = value + } +} + +export function filterAnalyticsCaptureResult(data: CaptureResult): CaptureResult { + const properties = (data.properties ?? {}) as AnalyticsCaptureProperties + const filteredProperties: AnalyticsCaptureProperties = {} + + setPropertyIfDefined(filteredProperties, "token", properties.token) + setPropertyIfDefined(filteredProperties, "distinct_id", properties.distinct_id) + setPropertyIfDefined(filteredProperties, "feature", properties.feature) + setPropertyIfDefined(filteredProperties, "surface", properties.surface) + setPropertyIfDefined(filteredProperties, "outcome", properties.outcome) + setPropertyIfDefined(filteredProperties, "latency_ms", properties.latency_ms) + setPropertyIfDefined(filteredProperties, "$browser", properties.$browser) + setPropertyIfDefined(filteredProperties, "$browser_version", properties.$browser_version) + setPropertyIfDefined(filteredProperties, "$insert_id", properties.$insert_id) + setPropertyIfDefined(filteredProperties, "$time", properties.$time) + setPropertyIfDefined(filteredProperties, "$lib", properties.$lib) + setPropertyIfDefined(filteredProperties, "$lib_version", properties.$lib_version) + setPropertyIfDefined(filteredProperties, "$process_person_profile", properties.$process_person_profile) + setPropertyIfDefined(filteredProperties, "extension_version", properties.extension_version) + + return { + ...data, + properties: filteredProperties, + } +} + +export function createBackgroundAnalytics( + runtime: BackgroundAnalyticsRuntime = createDefaultRuntime(), +) { + let clientPromise: Promise | null = null + let missingConfigWarned = false + + async function isAnalyticsEnabled(): Promise { + const enabled = await runtime.getStorageItem(`local:${ANALYTICS_ENABLED_STORAGE_KEY}`) + return typeof enabled === "boolean" ? enabled : runtime.defaultAnalyticsEnabled + } + + async function getAnalyticsInstallId(): Promise { + if (typeof runtime.distinctIdOverride === "string" && runtime.distinctIdOverride.length > 0) { + return runtime.distinctIdOverride + } + + const storageKey = `local:${ANALYTICS_INSTALL_ID_STORAGE_KEY}` + const existingId = await runtime.getStorageItem(storageKey) + + if (typeof existingId === "string" && existingId.length > 0) { + return existingId + } + + const nextId = runtime.createDistinctId() + await runtime.setStorageItem(storageKey, nextId) + return nextId + } + + async function getPostHogClient(): Promise { + if (!runtime.apiKey || !runtime.apiHost) { + if (!missingConfigWarned) { + missingConfigWarned = true + runtime.warn("[Analytics] PostHog is disabled because WXT_POSTHOG_API_KEY or WXT_POSTHOG_HOST is missing") + } + return null + } + + if (!clientPromise) { + clientPromise = (async () => { + const distinctId = await getAnalyticsInstallId() + + runtime.posthog.init(runtime.apiKey!, { + before_send: filterAnalyticsCaptureResult, + api_host: runtime.apiHost!, + autocapture: false, + save_campaign_params: false, + save_referrer: false, + capture_pageview: false, + capture_pageleave: false, + disable_external_dependency_loading: true, + disable_session_recording: true, + advanced_disable_flags: true, + person_profiles: "never", + persistence: "memory", + respect_dnt: true, + bootstrap: { + distinctID: distinctId, + }, + }) + + runtime.posthog.register({ + extension_version: runtime.extensionVersion, + }) + + return runtime.posthog + })() + } + + return clientPromise + } + + async function captureFeatureUsedEventInBackground( + properties: FeatureUsedEventProperties, + ): Promise { + if (!await isAnalyticsEnabled()) { + return + } + + try { + const client = await getPostHogClient() + if (!client) { + return + } + + client.capture(ANALYTICS_FEATURE_USED_EVENT, properties) + } + catch (error) { + runtime.warn(`[Analytics] Failed to capture ${ANALYTICS_FEATURE_USED_EVENT} in background`, error) + } + } + + function setupAnalyticsMessageHandlers(): void { + runtime.onMessage("trackFeatureUsedEvent", async (message) => { + await captureFeatureUsedEventInBackground(message.data) + }) + } + + return { + captureFeatureUsedEventInBackground, + setupAnalyticsMessageHandlers, + } +} + +const backgroundAnalytics = createBackgroundAnalytics() + +export const { + captureFeatureUsedEventInBackground, + setupAnalyticsMessageHandlers, +} = backgroundAnalytics diff --git a/src/entrypoints/background/context-menu.ts b/src/entrypoints/background/context-menu.ts index c63cb6b50..fb72ac24d 100644 --- a/src/entrypoints/background/context-menu.ts +++ b/src/entrypoints/background/context-menu.ts @@ -1,6 +1,8 @@ import type { Browser } from "#imports" import type { Config } from "@/types/config/config" import { browser, i18n, storage } from "#imports" +import { ANALYTICS_FEATURE, ANALYTICS_SURFACE } from "@/types/analytics" +import { createFeatureUsageContext } from "@/utils/analytics" import { CONFIG_STORAGE_KEY } from "@/utils/constants/config" import { getTranslationStateKey, TRANSLATION_STATE_KEY_PREFIX } from "@/utils/constants/storage-keys" import { sendMessage } from "@/utils/message" @@ -163,7 +165,12 @@ async function handleTranslateClick(tabId: number) { await storage.setItem(getTranslationStateKey(tabId), { enabled: newState }) // Notify content script in that specific tab - void sendMessage("askManagerToTogglePageTranslation", { enabled: newState }, tabId) + void sendMessage("askManagerToTogglePageTranslation", { + enabled: newState, + analyticsContext: newState + ? createFeatureUsageContext(ANALYTICS_FEATURE.PAGE_TRANSLATION, ANALYTICS_SURFACE.CONTEXT_MENU) + : undefined, + }, tabId) // Update menu title immediately await updateTranslateMenuTitle(tabId) diff --git a/src/entrypoints/background/index.ts b/src/entrypoints/background/index.ts index 0f199931d..4061dc112 100644 --- a/src/entrypoints/background/index.ts +++ b/src/entrypoints/background/index.ts @@ -5,6 +5,7 @@ import { logger } from "@/utils/logger" import { onMessage } from "@/utils/message" import { SessionCacheGroupRegistry } from "@/utils/session-cache/session-cache-group-registry" import { runAiSegmentSubtitles } from "./ai-segmentation" +import { setupAnalyticsMessageHandlers } from "./analytics" import { dispatchBackgroundStreamPort } from "./background-stream" import { ensureInitializedConfig } from "./config" import { setUpConfigBackup } from "./config-backup" @@ -78,6 +79,7 @@ export default defineBackground({ }) newUserGuide() + setupAnalyticsMessageHandlers() translationMessage() // Register context menu listeners synchronously diff --git a/src/entrypoints/background/translation-signal.ts b/src/entrypoints/background/translation-signal.ts index 3c5d64f8d..db681bd44 100644 --- a/src/entrypoints/background/translation-signal.ts +++ b/src/entrypoints/background/translation-signal.ts @@ -1,6 +1,8 @@ import type { Config } from "@/types/config/config" import type { TranslationState } from "@/types/translation-state" import { browser, storage } from "#imports" +import { ANALYTICS_FEATURE, ANALYTICS_SURFACE } from "@/types/analytics" +import { createFeatureUsageContext } from "@/utils/analytics" import { CONFIG_STORAGE_KEY } from "@/utils/constants/config" import { getTranslationStateKey } from "@/utils/constants/storage-keys" import { shouldEnableAutoTranslation } from "@/utils/host/translate/auto-translation" @@ -23,16 +25,16 @@ export function translationMessage() { }) onMessage("tryToSetEnablePageTranslationByTabId", async (msg) => { - const { tabId, enabled } = msg.data - void sendMessage("askManagerToTogglePageTranslation", { enabled }, tabId) + const { tabId, enabled, analyticsContext } = msg.data + void sendMessage("askManagerToTogglePageTranslation", { enabled, analyticsContext }, tabId) }) onMessage("tryToSetEnablePageTranslationOnContentScript", async (msg) => { const tabId = msg.sender?.tab?.id - const { enabled } = msg.data + const { enabled, analyticsContext } = msg.data if (typeof tabId === "number") { logger.info("sending tryToSetEnablePageTranslationOnContentScript to manager", { enabled, tabId }) - await sendMessage("askManagerToTogglePageTranslation", { enabled }, tabId) + await sendMessage("askManagerToTogglePageTranslation", { enabled, analyticsContext }, tabId) } else { logger.error("tabId is not a number", msg) @@ -48,7 +50,10 @@ export function translationMessage() { return const shouldEnable = await shouldEnableAutoTranslation(url, detectedCodeOrUnd, config) if (shouldEnable) { - void sendMessage("askManagerToTogglePageTranslation", { enabled: true }, tabId) + void sendMessage("askManagerToTogglePageTranslation", { + enabled: true, + analyticsContext: createFeatureUsageContext(ANALYTICS_FEATURE.PAGE_TRANSLATION, ANALYTICS_SURFACE.PAGE_AUTO), + }, tabId) } } }) diff --git a/src/entrypoints/host.content/index.tsx b/src/entrypoints/host.content/index.tsx index e2c60aa3d..65b941090 100644 --- a/src/entrypoints/host.content/index.tsx +++ b/src/entrypoints/host.content/index.tsx @@ -94,10 +94,10 @@ export default defineContentScript({ // Listen for translation state changes from background const cleanupTranslationStateListener = onMessage("askManagerToTogglePageTranslation", (msg) => { - const { enabled } = msg.data + const { enabled, analyticsContext } = msg.data if (enabled === manager.isActive) return - enabled ? void manager.start() : manager.stop() + enabled ? void manager.start(window === window.top ? analyticsContext : undefined) : manager.stop() }) ctx.onInvalidated(() => { diff --git a/src/entrypoints/host.content/translation-control/bind-translation-shortcut.ts b/src/entrypoints/host.content/translation-control/bind-translation-shortcut.ts index 97fa73ca9..b18ddd646 100644 --- a/src/entrypoints/host.content/translation-control/bind-translation-shortcut.ts +++ b/src/entrypoints/host.content/translation-control/bind-translation-shortcut.ts @@ -1,5 +1,7 @@ import type { PageTranslationManager } from "./page-translation" import hotkeys from "hotkeys-js" +import { ANALYTICS_FEATURE, ANALYTICS_SURFACE } from "@/types/analytics" +import { createFeatureUsageContext } from "@/utils/analytics" import { getLocalConfig } from "@/utils/config/storage" /** @@ -21,7 +23,9 @@ export async function bindTranslationShortcutKey(pageTranslationManager: PageTra pageTranslationManager.stop() } else { - void pageTranslationManager.start() + void pageTranslationManager.start( + createFeatureUsageContext(ANALYTICS_FEATURE.PAGE_TRANSLATION, ANALYTICS_SURFACE.SHORTCUT), + ) } return false }) diff --git a/src/entrypoints/host.content/translation-control/page-translation.ts b/src/entrypoints/host.content/translation-control/page-translation.ts index 3205d3d49..e129e1905 100644 --- a/src/entrypoints/host.content/translation-control/page-translation.ts +++ b/src/entrypoints/host.content/translation-control/page-translation.ts @@ -1,3 +1,6 @@ +import type { FeatureUsageContext } from "@/types/analytics" +import { ANALYTICS_FEATURE, ANALYTICS_SURFACE } from "@/types/analytics" +import { createFeatureUsageContext, trackFeatureUsed } from "@/utils/analytics" import { getDetectedCodeFromStorage } from "@/utils/config/languages" import { getLocalConfig } from "@/utils/config/storage" import { CONTENT_WRAPPER_CLASS } from "@/utils/constants/dom-labels" @@ -25,7 +28,7 @@ interface IPageTranslationManager { * Starts the automatic page translation functionality * Registers observers, touch triggers and set storage */ - start: () => Promise + start: (analyticsContext?: FeatureUsageContext) => Promise /** * Stops the automatic page translation functionality @@ -76,15 +79,23 @@ export class PageTranslationManager implements IPageTranslationManager { return this.isPageTranslating } - async start(): Promise { + async start(analyticsContext?: FeatureUsageContext): Promise { if (this.isPageTranslating) { console.warn("PageTranslationManager is already active") return } + const trackedContext = window === window.top ? analyticsContext : undefined + const config = await getLocalConfig() if (!config) { console.warn("Config is not initialized") + if (trackedContext) { + void trackFeatureUsed({ + ...trackedContext, + outcome: "failure", + }) + } return } @@ -95,44 +106,68 @@ export class PageTranslationManager implements IPageTranslationManager { translate: config.translate, language: config.language, }, detectedCode)) { + if (trackedContext) { + void trackFeatureUsed({ + ...trackedContext, + outcome: "failure", + }) + } return } - await sendMessage("setAndNotifyPageTranslationStateChangedByManager", { - enabled: true, - }) - - this.isPageTranslating = true - await this.primeDocumentTitleContext(config.translate.enableAIContentAware) - this.startDocumentTitleTracking() - - // Listen to existing elements when they enter the viewpoint - const walkId = crypto.randomUUID() - this.walkId = walkId - this.intersectionObserver = new IntersectionObserver(async (entries, observer) => { - for (const entry of entries) { - if (entry.isIntersecting) { - if (isHTMLElement(entry.target)) { - if (!entry.target.closest(`.${CONTENT_WRAPPER_CLASS}`)) { - const currentConfig = await getLocalConfig() - if (!currentConfig) { - logger.error("Global config is not initialized") - return + try { + await sendMessage("setAndNotifyPageTranslationStateChangedByManager", { + enabled: true, + }) + + this.isPageTranslating = true + await this.primeDocumentTitleContext(config.translate.enableAIContentAware) + this.startDocumentTitleTracking() + + // Listen to existing elements when they enter the viewpoint + const walkId = crypto.randomUUID() + this.walkId = walkId + this.intersectionObserver = new IntersectionObserver(async (entries, observer) => { + for (const entry of entries) { + if (entry.isIntersecting) { + if (isHTMLElement(entry.target)) { + if (!entry.target.closest(`.${CONTENT_WRAPPER_CLASS}`)) { + const currentConfig = await getLocalConfig() + if (!currentConfig) { + logger.error("Global config is not initialized") + return + } + void translateWalkedElement(entry.target, walkId, currentConfig) } - void translateWalkedElement(entry.target, walkId, currentConfig) } + observer.unobserve(entry.target) } - observer.unobserve(entry.target) } - } - }, this.intersectionOptions) + }, this.intersectionOptions) - // Initialize walkability state for existing elements - this.addDontWalkIntoElements(document.body) - await this.observerTopLevelParagraphs(document.body) + // Initialize walkability state for existing elements + this.addDontWalkIntoElements(document.body) + await this.observerTopLevelParagraphs(document.body) - // Start observing mutations from document.body and all shadow roots - this.observeMutations(document.body) + // Start observing mutations from document.body and all shadow roots + this.observeMutations(document.body) + + if (trackedContext) { + void trackFeatureUsed({ + ...trackedContext, + outcome: "success", + }) + } + } + catch (error) { + if (trackedContext) { + void trackFeatureUsed({ + ...trackedContext, + outcome: "failure", + }) + } + throw error + } } stop(): void { @@ -196,8 +231,14 @@ export class PageTranslationManager implements IPageTranslationManager { const onEnd = () => { if (!startTouches) return - if (performance.now() - startTime < PageTranslationManager.MAX_DURATION) - this.isPageTranslating ? this.stop() : void this.start() + if (performance.now() - startTime < PageTranslationManager.MAX_DURATION) { + this.isPageTranslating + ? this.stop() + : void this.start(createFeatureUsageContext( + ANALYTICS_FEATURE.PAGE_TRANSLATION, + ANALYTICS_SURFACE.TOUCH_GESTURE, + )) + } reset() } diff --git a/src/entrypoints/options/command-palette/search-items.ts b/src/entrypoints/options/command-palette/search-items.ts index 314150d30..e58058398 100644 --- a/src/entrypoints/options/command-palette/search-items.ts +++ b/src/entrypoints/options/command-palette/search-items.ts @@ -1,3 +1,7 @@ +import type { GeneratedI18nStructure } from "#i18n" + +type I18nKey = keyof GeneratedI18nStructure + export interface SearchItem { sectionId: string route: string @@ -8,6 +12,60 @@ export interface SearchItem { const IS_FIREFOX = import.meta.env.BROWSER === "firefox" +type SearchItemDefinition = Omit & { + titleKey: I18nKey + descriptionKey?: I18nKey + pageKey: I18nKey +} + +const TTS_SEARCH_ITEMS: SearchItemDefinition[] = !IS_FIREFOX + ? [{ + sectionId: "tts-config", + route: "/tts", + titleKey: "options.tts.title", + descriptionKey: "options.tts.description", + pageKey: "options.tts.title", + }] + : [] + +const CONFIG_SEARCH_ITEMS = [ + { + sectionId: "beta-experience", + route: "/config", + titleKey: "options.betaExperience.title", + descriptionKey: "options.betaExperience.description", + pageKey: "options.config.title", + }, + { + sectionId: "google-drive-sync", + route: "/config", + titleKey: "options.config.sync.googleDrive.title", + descriptionKey: "options.config.sync.googleDrive.description", + pageKey: "options.config.title", + }, + { + sectionId: "manual-config-sync", + route: "/config", + titleKey: "options.config.sync.title", + descriptionKey: "options.config.sync.description", + pageKey: "options.config.title", + }, + { + sectionId: "config-backup", + route: "/config", + titleKey: "options.config.backup.title", + descriptionKey: "options.config.backup.description", + pageKey: "options.config.title", + }, + { + sectionId: "reset-config", + route: "/config", + titleKey: "options.config.resetConfig.title", + descriptionKey: "options.config.resetConfig.description", + pageKey: "options.config.title", + }, +] satisfies SearchItemDefinition[] + export const SEARCH_ITEMS: SearchItem[] = [ // General page { @@ -271,50 +329,8 @@ export const SEARCH_ITEMS: SearchItem[] = [ }, // Text to Speech page - ...(!IS_FIREFOX - ? [{ - sectionId: "tts-config", - route: "/tts", - titleKey: "options.tts.title", - descriptionKey: "options.tts.description", - pageKey: "options.tts.title", - }] - : []), + ...TTS_SEARCH_ITEMS, // Config page - { - sectionId: "beta-experience", - route: "/config", - titleKey: "options.betaExperience.title", - descriptionKey: "options.betaExperience.description", - pageKey: "options.config.title", - }, - { - sectionId: "google-drive-sync", - route: "/config", - titleKey: "options.config.sync.googleDrive.title", - descriptionKey: "options.config.sync.googleDrive.description", - pageKey: "options.config.title", - }, - { - sectionId: "manual-config-sync", - route: "/config", - titleKey: "options.config.sync.title", - descriptionKey: "options.config.sync.description", - pageKey: "options.config.title", - }, - { - sectionId: "config-backup", - route: "/config", - titleKey: "options.config.backup.title", - descriptionKey: "options.config.backup.description", - pageKey: "options.config.title", - }, - { - sectionId: "reset-config", - route: "/config", - titleKey: "options.config.resetConfig.title", - descriptionKey: "options.config.resetConfig.description", - pageKey: "options.config.title", - }, -] + ...CONFIG_SEARCH_ITEMS, +] satisfies SearchItemDefinition[] diff --git a/src/entrypoints/options/command-palette/settings-search.tsx b/src/entrypoints/options/command-palette/settings-search.tsx index 22d1bc885..913feab17 100644 --- a/src/entrypoints/options/command-palette/settings-search.tsx +++ b/src/entrypoints/options/command-palette/settings-search.tsx @@ -19,6 +19,12 @@ import { scrollToSectionWhenReady, } from "./section-scroll" +type SearchI18nKey = Parameters[0] + +function tSearchKey(key: string) { + return i18n.t(key as SearchI18nKey) +} + export function SettingsSearch() { const [open, setOpen] = useAtom(commandPaletteOpenAtom) const navigate = useNavigate() @@ -92,14 +98,14 @@ export function SettingsSearch() { {i18n.t("options.commandPalette.noResults")} {Array.from(groupedItems.entries(), ([pageKey, items]) => ( - + {items.map(item => ( handleSelect(item)} > - {i18n.t(item.titleKey)} + {tSearchKey(item.titleKey)} ))} @@ -111,9 +117,9 @@ export function SettingsSearch() { } function buildSearchValue(item: (typeof SEARCH_ITEMS)[number]): string { - const parts = [i18n.t(item.titleKey)] + const parts = [tSearchKey(item.titleKey)] if (item.descriptionKey) { - parts.push(i18n.t(item.descriptionKey)) + parts.push(tSearchKey(item.descriptionKey)) } return parts.join(" ") } diff --git a/src/entrypoints/options/pages/api-providers/provider-config-form/feature-provider-section.tsx b/src/entrypoints/options/pages/api-providers/provider-config-form/feature-provider-section.tsx index 34ff081a9..43110a420 100644 --- a/src/entrypoints/options/pages/api-providers/provider-config-form/feature-provider-section.tsx +++ b/src/entrypoints/options/pages/api-providers/provider-config-form/feature-provider-section.tsx @@ -8,7 +8,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component import { Switch } from "@/components/ui/base-ui/switch" import { isLLMProvider } from "@/types/config/provider" import { configAtom, writeConfigAtom } from "@/utils/atoms/config" -import { buildFeatureProviderPatch, FEATURE_KEY_I18N_MAP, FEATURE_KEYS, FEATURE_PROVIDER_DEFS } from "@/utils/constants/feature-providers" +import { buildFeatureProviderPatch, FEATURE_KEYS, FEATURE_PROVIDER_DEFS, getFeatureLabelI18nKey } from "@/utils/constants/feature-providers" import { cn } from "@/utils/styles/utils" import { withForm } from "./form" @@ -81,7 +81,7 @@ export const FeatureProviderSection = withForm({ }} /> - {i18n.t(`options.general.featureProviders.features.${FEATURE_KEY_I18N_MAP[featureKey]}`)} + {i18n.t(getFeatureLabelI18nKey(featureKey))} ) diff --git a/src/entrypoints/options/pages/api-providers/providers-config.tsx b/src/entrypoints/options/pages/api-providers/providers-config.tsx index 38b2c6834..da340d01e 100644 --- a/src/entrypoints/options/pages/api-providers/providers-config.tsx +++ b/src/entrypoints/options/pages/api-providers/providers-config.tsx @@ -16,7 +16,7 @@ import { isAPIProviderConfig } from "@/types/config/provider" import { configAtom, configFieldsAtomMap } from "@/utils/atoms/config" import { providerConfigAtom } from "@/utils/atoms/provider" import { getAPIProvidersConfig } from "@/utils/config/helpers" -import { FEATURE_KEY_I18N_MAP, FEATURE_KEYS, FEATURE_PROVIDER_DEFS } from "@/utils/constants/feature-providers" +import { FEATURE_KEYS, FEATURE_PROVIDER_DEFS, getFeatureLabelI18nKey } from "@/utils/constants/feature-providers" import { API_PROVIDER_ITEMS } from "@/utils/constants/providers" import { cn } from "@/utils/styles/utils" import { ConfigCard } from "../../components/config-card" @@ -161,7 +161,7 @@ function ProviderCard({ providerConfig }: { providerConfig: APIProviderConfig })
    {assignedFeatures.map(key => ( -
  • {i18n.t(`options.general.featureProviders.features.${FEATURE_KEY_I18N_MAP[key]}`)}
  • +
  • {i18n.t(getFeatureLabelI18nKey(key))}
  • ))} {isLanguageDetectionProvider && (
  • {i18n.t("options.general.languageDetection.title")}
  • diff --git a/src/entrypoints/options/pages/config/about-card.tsx b/src/entrypoints/options/pages/config/about-card.tsx new file mode 100644 index 000000000..86b0de878 --- /dev/null +++ b/src/entrypoints/options/pages/config/about-card.tsx @@ -0,0 +1,41 @@ +import { i18n } from "#imports" +import { useAtom } from "jotai" +import { HelpTooltip } from "@/components/help-tooltip" +import { Switch } from "@/components/ui/base-ui/switch" +import { analyticsEnabledAtom } from "@/utils/atoms/analytics" +import { ConfigCard } from "../../components/config-card" + +export function AboutCard() { + const [analyticsEnabled, setAnalyticsEnabled] = useAtom(analyticsEnabledAtom) + + return ( + +
    +

    + {i18n.t("options.config.about.mission")} +

    + +
    +
    + + {i18n.t("options.config.about.analytics.title")} + + + {i18n.t("options.config.about.analytics.tooltip")} + +
    + { + void setAnalyticsEnabled(checked) + }} + /> +
    +
    +
    + ) +} diff --git a/src/entrypoints/options/pages/config/index.tsx b/src/entrypoints/options/pages/config/index.tsx index acd160b8a..adaaae542 100644 --- a/src/entrypoints/options/pages/config/index.tsx +++ b/src/entrypoints/options/pages/config/index.tsx @@ -1,5 +1,6 @@ import { i18n } from "#imports" import { PageLayout } from "../../components/page-layout" +import { AboutCard } from "./about-card" import { BetaExperienceConfig } from "./beta-experience" import { ConfigBackup } from "./config-backup" import { GoogleDriveSyncCard } from "./google-drive-sync" @@ -13,6 +14,7 @@ export function ConfigPage() { + ) diff --git a/src/entrypoints/options/pages/custom-actions/action-config-form/icon-field.tsx b/src/entrypoints/options/pages/custom-actions/action-config-form/icon-field.tsx index c3c88e096..e0d26b4d9 100644 --- a/src/entrypoints/options/pages/custom-actions/action-config-form/icon-field.tsx +++ b/src/entrypoints/options/pages/custom-actions/action-config-form/icon-field.tsx @@ -51,7 +51,7 @@ export const IconField = withForm({ {field.state.meta.errors.length > 0 && ( - {field.state.meta.errors.map(error => typeof error === "string" ? error : error?.message).join(", ")} + {field.state.meta.errors.join(", ")} )} diff --git a/src/entrypoints/options/pages/custom-actions/action-config-form/output-schema-field.tsx b/src/entrypoints/options/pages/custom-actions/action-config-form/output-schema-field.tsx index 8f793b614..57603407c 100644 --- a/src/entrypoints/options/pages/custom-actions/action-config-form/output-schema-field.tsx +++ b/src/entrypoints/options/pages/custom-actions/action-config-form/output-schema-field.tsx @@ -50,7 +50,29 @@ import { } from "@/utils/constants/custom-action" import { withForm } from "./form" -const t = (key: string) => i18n.t(`options.floatingButtonAndToolbar.selectionToolbar.customActions.form.${key}`) +type CustomActionFormKey = "fieldName" + | "fieldNamePlaceholder" + | "fieldType" + | "fieldDescription" + | "fieldDescriptionPlaceholder" + | "fieldSpeaking" + | "editFieldDialog.save" + | "deleteFieldDialog.title" + | "deleteFieldDialog.description" + | "deleteFieldDialog.cancel" + | "deleteFieldDialog.confirm" + | "outputSchema" + | "autoFieldPrefix" + | "addField" + | "addFieldDialog.title" + | "editFieldDialog.title" + +type CustomActionFormI18nKey = `options.floatingButtonAndToolbar.selectionToolbar.customActions.form.${CustomActionFormKey}` +type CustomActionTokenI18nKey = `options.floatingButtonAndToolbar.selectionToolbar.customActions.form.tokens.${(typeof SELECTION_TOOLBAR_CUSTOM_ACTION_TOKENS)[number]}` + +function t(key: CustomActionFormKey) { + return i18n.t(`options.floatingButtonAndToolbar.selectionToolbar.customActions.form.${key}` as CustomActionFormI18nKey) +} function FieldDialog({ field: outputField, @@ -91,7 +113,7 @@ function FieldDialog({ const customActionInsertCells = SELECTION_TOOLBAR_CUSTOM_ACTION_TOKENS.map(token => ({ text: getSelectionToolbarCustomActionTokenCellText(token), - description: i18n.t(`options.floatingButtonAndToolbar.selectionToolbar.customActions.form.tokens.${token}`), + description: i18n.t(`options.floatingButtonAndToolbar.selectionToolbar.customActions.form.tokens.${token}` as CustomActionTokenI18nKey), })) useEffect(() => { @@ -377,7 +399,7 @@ export const OutputSchemaField = withForm({ {field.state.meta.errors.length > 0 && ( - {field.state.meta.errors.map(error => typeof error === "string" ? error : error?.message).join(", ")} + {field.state.meta.errors.join(", ")} )} diff --git a/src/entrypoints/options/pages/custom-actions/action-config-form/provider-field.tsx b/src/entrypoints/options/pages/custom-actions/action-config-form/provider-field.tsx index cf0a18e55..7615b8db7 100644 --- a/src/entrypoints/options/pages/custom-actions/action-config-form/provider-field.tsx +++ b/src/entrypoints/options/pages/custom-actions/action-config-form/provider-field.tsx @@ -51,7 +51,7 @@ export const ProviderField = withForm({ /> {field.state.meta.errors.length > 0 && ( - {field.state.meta.errors.map(error => typeof error === "string" ? error : error?.message).join(", ")} + {field.state.meta.errors.join(", ")} )} diff --git a/src/entrypoints/options/pages/custom-actions/components/add-action-dialog.tsx b/src/entrypoints/options/pages/custom-actions/components/add-action-dialog.tsx index 914c28227..132633987 100644 --- a/src/entrypoints/options/pages/custom-actions/components/add-action-dialog.tsx +++ b/src/entrypoints/options/pages/custom-actions/components/add-action-dialog.tsx @@ -4,6 +4,12 @@ import { Icon } from "@iconify/react" import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/base-ui/dialog" import { CUSTOM_ACTION_TEMPLATES } from "@/utils/constants/custom-action-templates" +type TemplateI18nKey = Parameters[0] + +function tTemplateKey(key: string) { + return i18n.t(key as TemplateI18nKey) +} + export function AddActionDialog({ onSelect }: { onSelect: (template: CustomActionTemplate) => void }) { return ( @@ -25,8 +31,8 @@ export function AddActionDialog({ onSelect }: { onSelect: (template: CustomActio >
    -
    {i18n.t(template.nameKey)}
    -
    {i18n.t(template.descriptionKey)}
    +
    {tTemplateKey(template.nameKey)}
    +
    {tTemplateKey(template.descriptionKey)}
    ))} diff --git a/src/entrypoints/options/pages/general/feature-providers-config.tsx b/src/entrypoints/options/pages/general/feature-providers-config.tsx index 1e2b10005..f52044d03 100644 --- a/src/entrypoints/options/pages/general/feature-providers-config.tsx +++ b/src/entrypoints/options/pages/general/feature-providers-config.tsx @@ -9,7 +9,7 @@ import { isAPIProviderConfig, isLLMProviderConfig, isPureAPIProvider } from "@/t import { configAtom, configFieldsAtomMap, writeConfigAtom } from "@/utils/atoms/config" import { featureProviderConfigAtom } from "@/utils/atoms/provider" import { filterEnabledProvidersConfig, getProviderConfigById } from "@/utils/config/helpers" -import { buildFeatureProviderPatch, FEATURE_KEY_I18N_MAP, FEATURE_PROVIDER_DEFS } from "@/utils/constants/feature-providers" +import { buildFeatureProviderPatch, FEATURE_PROVIDER_DEFS, getFeatureLabelI18nKey } from "@/utils/constants/feature-providers" import { ConfigCard } from "../../components/config-card" import { SetApiKeyWarning } from "../../components/set-api-key-warning" @@ -41,7 +41,7 @@ function FeatureProviderField({ featureKey, excludeProviderTypes }: { return ( }> - {i18n.t(`options.general.featureProviders.features.${FEATURE_KEY_I18N_MAP[featureKey]}`)} + {i18n.t(getFeatureLabelI18nKey(featureKey))} {needsApiKeyWarning(providerConfig) && } { diff --git a/src/entrypoints/popup/components/translate-button.tsx b/src/entrypoints/popup/components/translate-button.tsx index 7920c69f5..0539f19b2 100644 --- a/src/entrypoints/popup/components/translate-button.tsx +++ b/src/entrypoints/popup/components/translate-button.tsx @@ -1,6 +1,8 @@ import { browser, i18n } from "#imports" import { useAtom, useAtomValue } from "jotai" import { Button } from "@/components/ui/base-ui/button" +import { ANALYTICS_FEATURE, ANALYTICS_SURFACE } from "@/types/analytics" +import { createFeatureUsageContext } from "@/utils/analytics" import { configFieldsAtomMap } from "@/utils/atoms/config" import { sendMessage } from "@/utils/message" import { formatHotkey } from "@/utils/os.ts" @@ -24,9 +26,13 @@ export default function TranslateButton({ className }: { className?: string }) { }) if (currentTab.id) { + const nextEnabled = !isPageTranslated void sendMessage("tryToSetEnablePageTranslationByTabId", { tabId: currentTab.id, - enabled: !isPageTranslated, + enabled: nextEnabled, + analyticsContext: nextEnabled + ? createFeatureUsageContext(ANALYTICS_FEATURE.PAGE_TRANSLATION, ANALYTICS_SURFACE.POPUP) + : undefined, }) setIsPageTranslated(prev => !prev) diff --git a/src/entrypoints/selection.content/components/speak-button.tsx b/src/entrypoints/selection.content/components/speak-button.tsx index 305875143..3450460f6 100644 --- a/src/entrypoints/selection.content/components/speak-button.tsx +++ b/src/entrypoints/selection.content/components/speak-button.tsx @@ -6,6 +6,7 @@ import { buttonVariants } from "@/components/ui/base-ui/button" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/base-ui/tooltip" import { useSelectionPopoverOverlayProps } from "@/components/ui/selection-popover" import { useTextToSpeech } from "@/hooks/use-text-to-speech" +import { ANALYTICS_SURFACE } from "@/types/analytics" import { configFieldsAtomMap } from "@/utils/atoms/config" import { cn } from "@/utils/styles/utils" @@ -15,7 +16,7 @@ export function SpeakButton({ text }: { text: string | undefined }) { const popoverOverlay = useSelectionPopoverOverlayProps() const [tooltipOpen, setTooltipOpen] = useState(false) const ttsConfig = useAtomValue(configFieldsAtomMap.tts) - const { play, stop, isFetching, isPlaying } = useTextToSpeech() + const { play, stop, isFetching, isPlaying } = useTextToSpeech(ANALYTICS_SURFACE.SELECTION_TOOLBAR) const handleClick = useCallback(() => { if (isFetching || isPlaying) { diff --git a/src/entrypoints/selection.content/input-translation/use-input-translation.ts b/src/entrypoints/selection.content/input-translation/use-input-translation.ts index 9bc10888e..69c401b74 100644 --- a/src/entrypoints/selection.content/input-translation/use-input-translation.ts +++ b/src/entrypoints/selection.content/input-translation/use-input-translation.ts @@ -1,5 +1,7 @@ import { useAtom } from "jotai" import { useCallback, useEffect, useRef } from "react" +import { ANALYTICS_FEATURE, ANALYTICS_SURFACE } from "@/types/analytics" +import { createFeatureUsageContext, trackFeatureAttempt } from "@/utils/analytics" import { configFieldsAtomMap } from "@/utils/atoms/config" import { translateTextForInput } from "@/utils/host/translate/translate-variants" @@ -189,7 +191,13 @@ export function useInputTranslation() { const originalText = text try { - const translatedText = await translateTextForInput(text, fromLang, toLang) + const translatedText = await trackFeatureAttempt( + createFeatureUsageContext( + ANALYTICS_FEATURE.INPUT_TRANSLATION, + ANALYTICS_SURFACE.INPUT_TRANSLATION, + ), + () => translateTextForInput(text, fromLang, toLang), + ) // Check if element content changed during translation (user input) let currentText: string diff --git a/src/entrypoints/selection.content/selection-toolbar/custom-action-button/field-speak-button.tsx b/src/entrypoints/selection.content/selection-toolbar/custom-action-button/field-speak-button.tsx index 19ed1bb8e..1fb079cf6 100644 --- a/src/entrypoints/selection.content/selection-toolbar/custom-action-button/field-speak-button.tsx +++ b/src/entrypoints/selection.content/selection-toolbar/custom-action-button/field-speak-button.tsx @@ -6,6 +6,7 @@ import { buttonVariants } from "@/components/ui/base-ui/button" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/base-ui/tooltip" import { useSelectionPopoverOverlayProps } from "@/components/ui/selection-popover" import { useTextToSpeech } from "@/hooks/use-text-to-speech" +import { ANALYTICS_SURFACE } from "@/types/analytics" import { configFieldsAtomMap } from "@/utils/atoms/config" import { cn } from "@/utils/styles/utils" @@ -21,7 +22,7 @@ export function FieldSpeakButton({ const popoverOverlay = useSelectionPopoverOverlayProps() const [tooltipOpen, setTooltipOpen] = useState(false) const ttsConfig = useAtomValue(configFieldsAtomMap.tts) - const { play, stop, isFetching, isPlaying } = useTextToSpeech() + const { play, stop, isFetching, isPlaying } = useTextToSpeech(ANALYTICS_SURFACE.SELECTION_TOOLBAR) const handleClick = useCallback(() => { if (disabled) { diff --git a/src/entrypoints/selection.content/selection-toolbar/speak-button.tsx b/src/entrypoints/selection.content/selection-toolbar/speak-button.tsx index 69c359f95..7a358e517 100644 --- a/src/entrypoints/selection.content/selection-toolbar/speak-button.tsx +++ b/src/entrypoints/selection.content/selection-toolbar/speak-button.tsx @@ -4,6 +4,7 @@ import { useAtomValue } from "jotai" import { useCallback, useState } from "react" import { toast } from "sonner" import { useTextToSpeech } from "@/hooks/use-text-to-speech" +import { ANALYTICS_SURFACE } from "@/types/analytics" import { configFieldsAtomMap } from "@/utils/atoms/config" import { SelectionToolbarTooltip } from "../components/selection-tooltip" import { selectionContentAtom } from "./atoms" @@ -13,7 +14,7 @@ const TOOLTIP_TRIGGER_PRESS_REASON = "trigger-press" export function SpeakButton() { const selectionContent = useAtomValue(selectionContentAtom) const ttsConfig = useAtomValue(configFieldsAtomMap.tts) - const { play, stop, isFetching, isPlaying } = useTextToSpeech() + const { play, stop, isFetching, isPlaying } = useTextToSpeech(ANALYTICS_SURFACE.SELECTION_TOOLBAR) const isBusy = isFetching || isPlaying const [tooltipOpen, setTooltipOpen] = useState(false) diff --git a/src/entrypoints/selection.content/selection-toolbar/translate-button/index.tsx b/src/entrypoints/selection.content/selection-toolbar/translate-button/index.tsx index ff4422711..9fabd6f48 100644 --- a/src/entrypoints/selection.content/selection-toolbar/translate-button/index.tsx +++ b/src/entrypoints/selection.content/selection-toolbar/translate-button/index.tsx @@ -8,7 +8,9 @@ import { RiTranslate } from "@remixicon/react" import { useAtomValue, useSetAtom } from "jotai" import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react" import { SelectionPopover } from "@/components/ui/selection-popover" +import { ANALYTICS_FEATURE, ANALYTICS_SURFACE } from "@/types/analytics" import { isLLMProviderConfig, isTranslateProviderConfig } from "@/types/config/provider" +import { createFeatureUsageContext, trackFeatureUsed } from "@/utils/analytics" import { configFieldsAtomMap, writeConfigAtom } from "@/utils/atoms/config" import { filterEnabledProvidersConfig } from "@/utils/config/helpers" import { buildFeatureProviderPatch } from "@/utils/constants/feature-providers" @@ -163,6 +165,11 @@ export function TranslateButton() { return } + const analyticsContext = createFeatureUsageContext( + ANALYTICS_FEATURE.SELECTION_TRANSLATION, + ANALYTICS_SURFACE.SELECTION_TOOLBAR, + ) + setIsTranslating(true) setTranslatedText(undefined) setThinking(null) @@ -174,6 +181,10 @@ export function TranslateButton() { setIsTranslating(false) setError(createSelectionToolbarPrecheckError("translate", "providerUnavailable")) } + void trackFeatureUsed({ + ...analyticsContext, + outcome: "failure", + }) return } @@ -182,6 +193,10 @@ export function TranslateButton() { setIsTranslating(false) setError(createSelectionToolbarPrecheckError("translate", "providerDisabled")) } + void trackFeatureUsed({ + ...analyticsContext, + outcome: "failure", + }) return } @@ -225,6 +240,11 @@ export function TranslateButton() { if (runIdRef.current === runId) { setTranslatedText(nextTranslatedText) } + + void trackFeatureUsed({ + ...analyticsContext, + outcome: "success", + }) } catch (error) { if (!isAbortError(error) && runIdRef.current === runId) { @@ -232,6 +252,13 @@ export function TranslateButton() { console.error("Translation error:", error) setError(createSelectionToolbarRuntimeError("translate", error)) } + + if (!isAbortError(error)) { + void trackFeatureUsed({ + ...analyticsContext, + outcome: "failure", + }) + } } finally { if (runIdRef.current === runId) { diff --git a/src/entrypoints/side.content/components/floating-button/index.tsx b/src/entrypoints/side.content/components/floating-button/index.tsx index 3cbf1374c..d1cb815b3 100644 --- a/src/entrypoints/side.content/components/floating-button/index.tsx +++ b/src/entrypoints/side.content/components/floating-button/index.tsx @@ -9,6 +9,8 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/base-ui/dropdown-menu" +import { ANALYTICS_FEATURE, ANALYTICS_SURFACE } from "@/types/analytics" +import { createFeatureUsageContext } from "@/utils/analytics" import { configFieldsAtomMap } from "@/utils/atoms/config" import { APP_NAME } from "@/utils/constants/app" import { sendMessage } from "@/utils/message" @@ -103,7 +105,13 @@ export default function FloatingButton() { // 只有未移动过才触发点击 if (!hasMoved) { if (floatingButton.clickAction === "translate") { - void sendMessage("tryToSetEnablePageTranslationOnContentScript", { enabled: !translationState.enabled }) + const nextEnabled = !translationState.enabled + void sendMessage("tryToSetEnablePageTranslationOnContentScript", { + enabled: nextEnabled, + analyticsContext: nextEnabled + ? createFeatureUsageContext(ANALYTICS_FEATURE.PAGE_TRANSLATION, ANALYTICS_SURFACE.FLOATING_BUTTON) + : undefined, + }) } else { setIsSideOpen(o => !o) diff --git a/src/entrypoints/subtitles.content/renderer/render-translate-button.ts b/src/entrypoints/subtitles.content/renderer/render-translate-button.ts index 14b5a1e40..8b81ab703 100644 --- a/src/entrypoints/subtitles.content/renderer/render-translate-button.ts +++ b/src/entrypoints/subtitles.content/renderer/render-translate-button.ts @@ -1,8 +1,9 @@ -import * as React from "react" -import themeCSS from "@/assets/styles/theme.css?inline" -import { TRANSLATE_BUTTON_CONTAINER_ID } from "@/utils/constants/subtitles" -import { createReactShadowHost } from "@/utils/react-shadow-host/create-shadow-host" -import { SubtitleToggleButton } from "../ui/subtitles-translate-button" +import type { FeatureUsageContext } from "@/types/analytics" +import * as React from "react" +import themeCSS from "@/assets/styles/theme.css?inline" +import { TRANSLATE_BUTTON_CONTAINER_ID } from "@/utils/constants/subtitles" +import { createReactShadowHost } from "@/utils/react-shadow-host/create-shadow-host" +import { SubtitleToggleButton } from "../ui/subtitles-translate-button" const wrapperCSS = ` :host { @@ -23,9 +24,9 @@ const wrapperCSS = ` } ` -export function renderSubtitlesTranslateButton( - onToggle: (enabled: boolean) => void, -): HTMLDivElement { +export function renderSubtitlesTranslateButton( + onToggle: (enabled: boolean, analyticsContext?: FeatureUsageContext) => void, +): HTMLDivElement { const existingContainer = document.querySelector(`#${TRANSLATE_BUTTON_CONTAINER_ID}`) if (existingContainer) { diff --git a/src/entrypoints/subtitles.content/ui/subtitles-translate-button.tsx b/src/entrypoints/subtitles.content/ui/subtitles-translate-button.tsx index 40e6ab5a4..f64f50e29 100644 --- a/src/entrypoints/subtitles.content/ui/subtitles-translate-button.tsx +++ b/src/entrypoints/subtitles.content/ui/subtitles-translate-button.tsx @@ -1,34 +1,52 @@ -import { useAtomValue } from "jotai" -import { useEffect, useEffectEvent } from "react" -import logo from "@/assets/icons/original/read-frog.png" +import type { FeatureUsageContext } from "@/types/analytics" +import { useAtomValue } from "jotai" +import { useEffect, useEffectEvent } from "react" +import logo from "@/assets/icons/original/read-frog.png" +import { ANALYTICS_FEATURE, ANALYTICS_SURFACE } from "@/types/analytics" +import { createFeatureUsageContext } from "@/utils/analytics" import { getLocalConfig } from "@/utils/config/storage" -import { TRANSLATE_BUTTON_CLASS } from "@/utils/constants/subtitles" -import { cn } from "@/utils/styles/utils" -import { subtitlesStore, subtitlesVisibleAtom } from "../atoms" - -export function SubtitleToggleButton( - { onToggle }: - { - onToggle: (enabled: boolean) => void - }, -) { +import { TRANSLATE_BUTTON_CLASS } from "@/utils/constants/subtitles" +import { cn } from "@/utils/styles/utils" +import { subtitlesStore, subtitlesVisibleAtom } from "../atoms" + +export function SubtitleToggleButton( + { onToggle }: + { + onToggle: (enabled: boolean, analyticsContext?: FeatureUsageContext) => void + }, +) { const isVisible = useAtomValue(subtitlesVisibleAtom, { store: subtitlesStore }) - const tryStartSubtitles = useEffectEvent(async () => { - const config = await getLocalConfig() - const autoStart = config?.videoSubtitles?.autoStart ?? false - if (autoStart) { - onToggle(true) - } - }) + const tryStartSubtitles = useEffectEvent(async () => { + const config = await getLocalConfig() + const autoStart = config?.videoSubtitles?.autoStart ?? false + if (autoStart) { + onToggle( + true, + createFeatureUsageContext( + ANALYTICS_FEATURE.VIDEO_SUBTITLES, + ANALYTICS_SURFACE.VIDEO_SUBTITLES_AUTO, + ), + ) + } + }) useEffect(() => { void tryStartSubtitles() }, []) - const handleClick = () => { - onToggle(!isVisible) - } + const handleClick = () => { + const nextEnabled = !isVisible + onToggle( + nextEnabled, + nextEnabled + ? createFeatureUsageContext( + ANALYTICS_FEATURE.VIDEO_SUBTITLES, + ANALYTICS_SURFACE.VIDEO_SUBTITLES, + ) + : undefined, + ) + } return (