diff options
| author | syuilo <4439005+syuilo@users.noreply.github.com> | 2025-02-25 20:51:23 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-02-25 11:51:23 +0000 |
| commit | 2b6638e1607e5c44eb6f6dc987e9e893927f1829 (patch) | |
| tree | f3efba7ca978bd5cf7d32202a014ffb73ac719f6 /packages | |
| parent | chore(config): migrate renovate config (#15550) (diff) | |
| download | misskey-2b6638e1607e5c44eb6f6dc987e9e893927f1829.tar.gz misskey-2b6638e1607e5c44eb6f6dc987e9e893927f1829.tar.bz2 misskey-2b6638e1607e5c44eb6f6dc987e9e893927f1829.zip | |
feat: google analytics (#15451)
* wip backend
* wip frontend
* build misskey-js
* implement control panel
* fix
* introduce analytics wrapper
* spdx
* Update analytics.ts
* Update common.ts
* wip
* wip
* wip
* wip
* wip
* Update CHANGELOG.md
---------
Co-authored-by: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/backend/migration/1739006797620-GoogleAnalytics.js | 16 | ||||
| -rw-r--r-- | packages/backend/src/core/entities/MetaEntityService.ts | 1 | ||||
| -rw-r--r-- | packages/backend/src/models/Meta.ts | 6 | ||||
| -rw-r--r-- | packages/backend/src/models/json-schema/meta.ts | 4 | ||||
| -rw-r--r-- | packages/backend/src/server/api/endpoints/admin/meta.ts | 5 | ||||
| -rw-r--r-- | packages/backend/src/server/api/endpoints/admin/update-meta.ts | 7 | ||||
| -rw-r--r-- | packages/frontend/package.json | 2 | ||||
| -rw-r--r-- | packages/frontend/src/analytics.ts | 107 | ||||
| -rw-r--r-- | packages/frontend/src/boot/common.ts | 16 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkPageWindow.vue | 16 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/external-services.vue | 53 | ||||
| -rw-r--r-- | packages/frontend/src/router/main.ts | 9 | ||||
| -rw-r--r-- | packages/misskey-js/src/autogen/types.ts | 3 |
13 files changed, 229 insertions, 16 deletions
diff --git a/packages/backend/migration/1739006797620-GoogleAnalytics.js b/packages/backend/migration/1739006797620-GoogleAnalytics.js new file mode 100644 index 0000000000..5871bf098a --- /dev/null +++ b/packages/backend/migration/1739006797620-GoogleAnalytics.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class GoogleAnalytics1739006797620 { + name = 'GoogleAnalytics1739006797620' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "googleAnalyticsMeasurementId" character varying(64)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "googleAnalyticsMeasurementId"`); + } +} diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index ec0b5360f4..7ad6071ceb 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -97,6 +97,7 @@ export class MetaEntityService { enableTurnstile: instance.enableTurnstile, turnstileSiteKey: instance.turnstileSiteKey, enableTestcaptcha: instance.enableTestcaptcha, + googleAnalyticsMeasurementId: instance.googleAnalyticsMeasurementId, swPublickey: instance.swPublicKey, themeColor: instance.themeColor, mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png', diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index ad5e31ad6f..9df2f74984 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -658,4 +658,10 @@ export class MiMeta { default: '{}', }) public federationHosts: string[]; + + @Column('varchar', { + length: 64, + nullable: true, + }) + public googleAnalyticsMeasurementId: string | null; } diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index e7ae2ee8e5..1e25c355ca 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -119,6 +119,10 @@ export const packedMetaLiteSchema = { type: 'boolean', optional: false, nullable: false, }, + googleAnalyticsMeasurementId: { + type: 'string', + optional: false, nullable: true, + }, swPublickey: { type: 'string', optional: false, nullable: true, diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 912c8defbe..9d5691a427 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -73,6 +73,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + googleAnalyticsMeasurementId: { + type: 'string', + optional: false, nullable: true, + }, swPublickey: { type: 'string', optional: false, nullable: true, @@ -572,6 +576,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- enableTurnstile: instance.enableTurnstile, turnstileSiteKey: instance.turnstileSiteKey, enableTestcaptcha: instance.enableTestcaptcha, + googleAnalyticsMeasurementId: instance.googleAnalyticsMeasurementId, swPublickey: instance.swPublicKey, themeColor: instance.themeColor, mascotImageUrl: instance.mascotImageUrl, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 38ef0d1de8..1cfa9cffa6 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -84,6 +84,7 @@ export const paramDef = { turnstileSiteKey: { type: 'string', nullable: true }, turnstileSecretKey: { type: 'string', nullable: true }, enableTestcaptcha: { type: 'boolean' }, + googleAnalyticsMeasurementId: { type: 'string', nullable: true }, sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] }, sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] }, setSensitiveFlagAutomatically: { type: 'boolean' }, @@ -371,6 +372,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- set.enableTestcaptcha = ps.enableTestcaptcha; } + if (ps.googleAnalyticsMeasurementId !== undefined) { + // 空文字列をnullにしたいので??は使わない + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + set.googleAnalyticsMeasurementId = ps.googleAnalyticsMeasurementId || null; + } + if (ps.sensitiveMediaDetection !== undefined) { set.sensitiveMediaDetection = ps.sensitiveMediaDetection; } diff --git a/packages/frontend/package.json b/packages/frontend/package.json index b2bc47ec83..10fbc58cf0 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -16,6 +16,7 @@ "lint": "pnpm typecheck && pnpm eslint" }, "dependencies": { + "@analytics/google-analytics": "1.1.0", "@discordapp/twemoji": "15.1.0", "@github/webauthn-json": "2.1.1", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", @@ -29,6 +30,7 @@ "@vitejs/plugin-vue": "5.2.1", "@vue/compiler-sfc": "3.5.13", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15", + "analytics": "0.8.16", "astring": "1.9.0", "broadcast-channel": "7.0.0", "buraha": "0.0.1", diff --git a/packages/frontend/src/analytics.ts b/packages/frontend/src/analytics.ts new file mode 100644 index 0000000000..e07a4e9258 --- /dev/null +++ b/packages/frontend/src/analytics.ts @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import type { AnalyticsInstance, AnalyticsPlugin } from 'analytics'; + +/** + * analytics moduleを読み込まなくても動作するようにするためのラッパー + */ +class AnalyticsProxy implements AnalyticsInstance { + private analytics?: AnalyticsInstance; + + constructor(analytics?: AnalyticsInstance) { + if (analytics) { + this.analytics = analytics; + } + } + + public setAnalytics(analytics: AnalyticsInstance) { + if (this.analytics) { + throw new Error('Analytics instance already exists.'); + } + this.analytics = analytics; + } + + public identify(...args: Parameters<AnalyticsInstance['identify']>) { + return this.analytics?.identify(...args) ?? Promise.resolve(); + } + + public track(...args: Parameters<AnalyticsInstance['track']>) { + return this.analytics?.track(...args) ?? Promise.resolve(); + } + + public page(...args: Parameters<AnalyticsInstance['page']>) { + return this.analytics?.page(...args) ?? Promise.resolve(); + } + + public user(...args: Parameters<AnalyticsInstance['user']>) { + return this.analytics?.user(...args) ?? Promise.resolve(); + } + + public reset(...args: Parameters<AnalyticsInstance['reset']>) { + return this.analytics?.reset(...args) ?? Promise.resolve(); + } + + public ready(...args: Parameters<AnalyticsInstance['ready']>) { + return this.analytics?.ready(...args) ?? function () { void 0; }; + } + + public on(...args: Parameters<AnalyticsInstance['on']>) { + return this.analytics?.on(...args) ?? function () { void 0; }; + } + + public once(...args: Parameters<AnalyticsInstance['once']>) { + return this.analytics?.once(...args) ?? function () { void 0; }; + } + + public getState(...args: Parameters<AnalyticsInstance['getState']>) { + return this.analytics?.getState(...args) ?? Promise.resolve(); + } + + public get storage() { + return this.analytics?.storage ?? { + getItem: () => null, + setItem: () => void 0, + removeItem: () => void 0, + }; + } + + public get plugins() { + return this.analytics?.plugins ?? { + enable: (p, c) => Promise.resolve(c ? c() : void 0), + disable: (p, c) => Promise.resolve(c ? c() : void 0), + }; + } +} + +export const analytics = new AnalyticsProxy(); + +export async function initAnalytics(instance: Misskey.entities.MetaDetailed) { + // アナリティクスプロバイダに関する設定がひとつもない場合は、アナリティクスモジュールを読み込まない + if (!instance.googleAnalyticsMeasurementId) { + return; + } + + const { default: Analytics } = await import('analytics'); + const plugins: AnalyticsPlugin[] = []; + + // Google Analytics + if (instance.googleAnalyticsMeasurementId) { + const { default: googleAnalytics } = await import('@analytics/google-analytics'); + + plugins.push(googleAnalytics({ + measurementIds: [instance.googleAnalyticsMeasurementId], + debug: _DEV_, + })); + } + + analytics.setAnalytics(Analytics({ + app: 'misskey', + version: _VERSION_, + debug: _DEV_, + plugins, + })); +} diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index 1d8e40a12d..d09b98efe0 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -4,9 +4,9 @@ */ import { computed, watch, version as vueVersion } from 'vue'; -import type { App } from 'vue'; import { compareVersions } from 'compare-versions'; import { version, lang, updateLocale, locale } from '@@/js/config.js'; +import type { App } from 'vue'; import widgets from '@/widgets/index.js'; import directives from '@/directives/index.js'; import components from '@/components/index.js'; @@ -21,6 +21,7 @@ import { reloadChannel } from '@/scripts/unison-reload.js'; import { getUrlWithoutLoginId } from '@/scripts/login-id.js'; import { getAccountFromId } from '@/scripts/get-account-from-id.js'; import { deckStore } from '@/ui/deck/deck-store.js'; +import { analytics, initAnalytics } from '@/analytics.js'; import { miLocalStorage } from '@/local-storage.js'; import { fetchCustomEmojis } from '@/custom-emojis.js'; import { setupRouter } from '@/router/main.js'; @@ -241,6 +242,19 @@ export async function common(createVue: () => App<Element>) { await fetchCustomEmojis(); } catch (err) { /* empty */ } + // analytics + fetchInstanceMetaPromise.then(async () => { + await initAnalytics(instance); + + if ($i) { + analytics.identify($i.id); + } + + analytics.page({ + path: window.location.pathname, + }); + }); + const app = createVue(); setupRouter(app, createMainRouter); diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index 1420b9c26f..e725d2a15d 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -32,6 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue'; import { url } from '@@/js/config.js'; import { getScrollContainer } from '@@/js/scroll.js'; +import type { PageMetadata } from '@/scripts/page-metadata.js'; import RouterView from '@/components/global/RouterView.vue'; import MkWindow from '@/components/MkWindow.vue'; import { popout as _popout } from '@/scripts/popout.js'; @@ -39,11 +40,11 @@ import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { useScrollPositionManager } from '@/nirax.js'; import { i18n } from '@/i18n.js'; import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; -import type { PageMetadata } from '@/scripts/page-metadata.js'; import { openingWindowsCount } from '@/os.js'; import { claimAchievement } from '@/scripts/achievements.js'; import { useRouterFactory } from '@/router/supplier.js'; import { mainRouter } from '@/router/main.js'; +import { analytics } from '@/analytics.js'; const props = defineProps<{ initialPath: string; @@ -99,6 +100,14 @@ windowRouter.addListener('replace', ctx => { history.value.push({ path: ctx.path, key: ctx.key }); }); +windowRouter.addListener('change', ctx => { + console.log('windowRouter: change', ctx.path); + analytics.page({ + path: ctx.path, + title: ctx.path, + }); +}); + windowRouter.init(); provide('router', windowRouter); @@ -160,6 +169,11 @@ function popout() { useScrollPositionManager(() => getScrollContainer(contents.value), windowRouter); onMounted(() => { + analytics.page({ + path: props.initialPath, + title: props.initialPath, + }); + openingWindowsCount.value++; if (openingWindowsCount.value >= 3) { claimAchievement('open3windows'); diff --git a/packages/frontend/src/pages/admin/external-services.vue b/packages/frontend/src/pages/admin/external-services.vue index 91f41166e9..a312ecce12 100644 --- a/packages/frontend/src/pages/admin/external-services.vue +++ b/packages/frontend/src/pages/admin/external-services.vue @@ -8,20 +8,34 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <FormSuspense :p="init"> - <MkFolder> - <template #label>DeepL Translation</template> + <div class="_gaps_m"> + <MkFolder> + <template #label>Google Analytics<span class="_beta">{{ i18n.ts.beta }}</span></template> - <div class="_gaps_m"> - <MkInput v-model="deeplAuthKey"> - <template #prefix><i class="ti ti-key"></i></template> - <template #label>DeepL Auth Key</template> - </MkInput> - <MkSwitch v-model="deeplIsPro"> - <template #label>Pro account</template> - </MkSwitch> - <MkButton primary @click="save_deepl">Save</MkButton> - </div> - </MkFolder> + <div class="_gaps_m"> + <MkInput v-model="googleAnalyticsMeasurementId"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>Measurement ID</template> + </MkInput> + <MkButton primary @click="save_googleAnalytics">Save</MkButton> + </div> + </MkFolder> + + <MkFolder> + <template #label>DeepL Translation</template> + + <div class="_gaps_m"> + <MkInput v-model="deeplAuthKey"> + <template #prefix><i class="ti ti-key"></i></template> + <template #label>DeepL Auth Key</template> + </MkInput> + <MkSwitch v-model="deeplIsPro"> + <template #label>Pro account</template> + </MkSwitch> + <MkButton primary @click="save_deepl">Save</MkButton> + </div> + </MkFolder> + </div> </FormSuspense> </MkSpacer> </MkStickyContainer> @@ -44,10 +58,13 @@ import MkFolder from '@/components/MkFolder.vue'; const deeplAuthKey = ref<string>(''); const deeplIsPro = ref<boolean>(false); +const googleAnalyticsMeasurementId = ref<string>(''); + async function init() { const meta = await misskeyApi('admin/meta'); - deeplAuthKey.value = meta.deeplAuthKey; + deeplAuthKey.value = meta.deeplAuthKey ?? ''; deeplIsPro.value = meta.deeplIsPro; + googleAnalyticsMeasurementId.value = meta.googleAnalyticsMeasurementId ?? ''; } function save_deepl() { @@ -59,6 +76,14 @@ function save_deepl() { }); } +function save_googleAnalytics() { + os.apiWithDialog('admin/update-meta', { + googleAnalyticsMeasurementId: googleAnalyticsMeasurementId.value, + }).then(() => { + fetchInstance(true); + }); +} + const headerActions = computed(() => []); const headerTabs = computed(() => []); diff --git a/packages/frontend/src/router/main.ts b/packages/frontend/src/router/main.ts index 4379b6a316..3932a8bac8 100644 --- a/packages/frontend/src/router/main.ts +++ b/packages/frontend/src/router/main.ts @@ -7,6 +7,7 @@ import { EventEmitter } from 'eventemitter3'; import type { IRouter, Resolved, RouteDef, RouterEvent, RouterFlag } from '@/nirax.js'; import type { App, ShallowRef } from 'vue'; +import { analytics } from '@/analytics.js'; /** * {@link Router}による画面遷移を可能とするために{@link mainRouter}をセットアップする。 @@ -29,6 +30,14 @@ export function setupRouter(app: App, routerFactory: ((path: string) => IRouter) window.history.replaceState({ key: ctx.key }, '', ctx.path); }); + mainRouter.addListener('change', ctx => { + console.log('mainRouter: change', ctx.path); + analytics.page({ + path: ctx.path, + title: ctx.path, + }); + }); + mainRouter.init(); setMainRouter(mainRouter); diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index c7485c6c3d..ae7a8c7440 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -5042,6 +5042,7 @@ export type components = { enableTurnstile: boolean; turnstileSiteKey: string | null; enableTestcaptcha: boolean; + googleAnalyticsMeasurementId: string | null; swPublickey: string | null; /** @default /assets/ai.png */ mascotImageUrl: string; @@ -8251,6 +8252,7 @@ export type operations = { enableTurnstile: boolean; turnstileSiteKey: string | null; enableTestcaptcha: boolean; + googleAnalyticsMeasurementId: string | null; swPublickey: string | null; /** @default /assets/ai.png */ mascotImageUrl: string | null; @@ -10617,6 +10619,7 @@ export type operations = { turnstileSiteKey?: string | null; turnstileSecretKey?: string | null; enableTestcaptcha?: boolean; + googleAnalyticsMeasurementId?: string | null; /** @enum {string} */ sensitiveMediaDetection?: 'none' | 'all' | 'local' | 'remote'; /** @enum {string} */ |