From 96466a050dbbb6b0acbedbcee0ec5c9508e09f52 Mon Sep 17 00:00:00 2001 From: linonetwo Date: Wed, 29 Apr 2026 18:10:37 +0800 Subject: [PATCH] feat(analytics): instrument app lifecycle, sync, theme, updater, workspace events --- src/main.ts | 34 +++++++++++++++++++++++++++- src/services/deepLink/index.ts | 11 +++++++-- src/services/native/reportError.ts | 8 +++++++ src/services/sync/index.ts | 28 +++++++++++++++++++++-- src/services/theme/index.ts | 6 +++++ src/services/updater/index.ts | 6 +++++ src/services/windows/index.ts | 7 ++++++ src/services/workspaces/index.ts | 8 +++++++ src/services/workspacesView/index.ts | 7 ++++++ 9 files changed, 110 insertions(+), 5 deletions(-) diff --git a/src/main.ts b/src/main.ts index fc7763cd..228cbca5 100755 --- a/src/main.ts +++ b/src/main.ts @@ -25,6 +25,8 @@ import serviceIdentifier from '@services/serviceIdentifier'; import { WindowNames } from '@services/windows/WindowProperties'; import type { IAgentDefinitionService } from '@services/agentDefinition/interface'; +import { sanitizeErrorMessage } from '@services/analytics'; +import type { IAnalyticsService } from '@services/analytics/interface'; import type { IContextService } from '@services/context/interface'; import type { IDatabaseService } from '@services/database/interface'; import type { IDeepLinkService } from '@services/deepLink/interface'; @@ -73,6 +75,7 @@ protocol.registerSchemesAsPrivileged([ bindServiceAndProxy(); // Get services - DO NOT use them until commonInit() is called +const analyticsService = container.get(serviceIdentifier.Analytics); const contextService = container.get(serviceIdentifier.Context); const databaseService = container.get(serviceIdentifier.Database); const preferenceService = container.get(serviceIdentifier.Preference); @@ -223,6 +226,14 @@ const commonInit = async (): Promise => { } // trigger whenTrulyReady ipcMain.emit(MainChannel.commonInitFinished); + + // Track app launch event with retention properties + const retentionProperties = await analyticsService.getRetentionProperties(); + void analyticsService.track('app.launched', { + platform: process.platform, + version: app.getVersion(), + ...retentionProperties, + }); }; /** @@ -249,7 +260,18 @@ app.on('ready', async () => { } await updaterService.checkForUpdates(); } catch (error) { - logger.error('Error during app ready handler', { function: "app.on('ready')", error }); + const error_ = error as Error; + logger.error('Error during app ready handler', { function: "app.on('ready')", error: error_ }); + // Fire-and-forget error tracking for post-init failures + try { + void analyticsService.track('error.unhandled', { + errorName: error_.name || 'Error', + errorMessage: sanitizeErrorMessage(error_), + errorSource: 'app_ready', + }); + } catch { + // Silently ignore — analytics infrastructure may not be ready + } } }); app.on(MainChannel.windowAllClosed, async () => { @@ -291,6 +313,16 @@ unhandled({ showDialog: !isDevelopmentOrTest, logger: (error: Error) => { logger.error('unhandled', { error }); + // Fire-and-forget error tracking. Wrapped to avoid throwing if services are not yet initialized. + try { + void analyticsService.track('error.unhandled', { + errorName: error.name || 'Error', + errorMessage: sanitizeErrorMessage(error), + errorSource: 'unhandled', + }); + } catch { + // Silently ignore — analytics infrastructure may not be ready during early startup + } }, reportButton: (error) => { reportErrorToGithubWithTemplates(error); diff --git a/src/services/deepLink/index.ts b/src/services/deepLink/index.ts index 751c4917..36bb6bf5 100644 --- a/src/services/deepLink/index.ts +++ b/src/services/deepLink/index.ts @@ -1,4 +1,6 @@ import { TIDGI_PROTOCOL_SCHEME } from '@/constants/protocol'; +import type { IAnalyticsService } from '@services/analytics/interface'; +import { container } from '@services/container'; import { logger } from '@services/libs/log'; import serviceIdentifier from '@services/serviceIdentifier'; import type { IWorkspaceService } from '@services/workspaces/interface'; @@ -54,8 +56,9 @@ export class DeepLinkService implements IDeepLinkService { * Handle link and open the workspace. * @param requestUrl like `tidgi://lxqsftvfppu_z4zbaadc0/#:Index` or `tidgi://lxqsftvfppu_z4zbaadc0/#%E6%96%B0%E6%9D%A1%E7%9B%AE` */ - private readonly deepLinkHandler: (requestUrl: string) => Promise = async (requestUrl) => { + private readonly deepLinkHandler: (requestUrl: string, fromPendingQueue?: boolean) => Promise = async (requestUrl, fromPendingQueue = false) => { logger.info(`Receiving deep link`, { requestUrl, function: 'deepLinkHandler' }); + const analyticsService = container.get(serviceIdentifier.Analytics); try { // hostname is workspace id or name const { hostname, hash, pathname } = new URL(requestUrl); @@ -93,6 +96,10 @@ export class DeepLinkService implements IDeepLinkService { } logger.info(`Open deep link`, { workspaceId: workspace.id, tiddlerName, function: 'deepLinkHandler' }); + void analyticsService.track('deep_link.opened', { + resolvedWorkspace: true, + fromPendingQueue, + }); await this.workspaceService.openWorkspaceTiddler(workspace, tiddlerName); } catch (error) { logger.error(`Invalid URL`, { requestUrl, error, function: 'deepLinkHandler' }); @@ -107,7 +114,7 @@ export class DeepLinkService implements IDeepLinkService { const url = this.pendingDeepLink; this.pendingDeepLink = undefined; logger.info(`Processing pending deep link`, { url, function: 'processPendingDeepLink' }); - await this.deepLinkHandler(url); + await this.deepLinkHandler(url, true); } } diff --git a/src/services/native/reportError.ts b/src/services/native/reportError.ts index 624c7d4d..546e9a57 100644 --- a/src/services/native/reportError.ts +++ b/src/services/native/reportError.ts @@ -1,4 +1,7 @@ import { LOG_FOLDER } from '@/constants/appPaths'; +import { sanitizeErrorMessage } from '@services/analytics'; +import type { IAnalyticsService } from '@services/analytics/interface'; +import { container } from '@services/container'; import serviceIdentifier from '@services/serviceIdentifier'; import { app, shell } from 'electron'; import newGithubIssueUrl, { type Options as OpenNewGitHubIssueOptions } from 'new-github-issue-url'; @@ -58,6 +61,11 @@ Locale: ${app.getLocale()} `.trim(); export function reportErrorToGithubWithTemplates(error: Error): void { + const analyticsService = container.get(serviceIdentifier.Analytics); + void analyticsService.track('error.report_requested', { + errorName: error.name || 'Error', + errorMessage: sanitizeErrorMessage(error), + }); void import('@services/container') .then(({ container }) => { const nativeService = container.get(serviceIdentifier.NativeService); diff --git a/src/services/sync/index.ts b/src/services/sync/index.ts index 7347c6e7..ddeba20c 100644 --- a/src/services/sync/index.ts +++ b/src/services/sync/index.ts @@ -1,6 +1,7 @@ import { inject, injectable } from 'inversify'; import { WikiChannel } from '@/constants/channels'; +import type { IAnalyticsService } from '@services/analytics/interface'; import type { IAuthenticationService } from '@services/auth/interface'; import { container } from '@services/container'; import type { ICommitAndSyncConfigs, IGitService } from '@services/git/interface'; @@ -33,6 +34,7 @@ export class Sync implements ISyncService { // Get Layer 3 services const wikiService = container.get(serviceIdentifier.Wiki); const gitService = container.get(serviceIdentifier.Git); + const analyticsService = container.get(serviceIdentifier.Analytics); const workspaceService = container.get(serviceIdentifier.Workspace); const workspaceViewService = container.get(serviceIdentifier.WorkspaceView); @@ -43,23 +45,37 @@ export class Sync implements ISyncService { const defaultCommitMessage = i18n.t('LOG.CommitMessage'); const commitMessage = useAICommitMessage ? undefined : (overrideCommitMessage ?? defaultCommitMessage); const localCommitMessage = useAICommitMessage ? undefined : overrideCommitMessage; + const { force = false } = options ?? {}; const syncOnlyWhenNoDraft = await this.preferenceService.get('syncOnlyWhenNoDraft'); const mainWorkspace = isSubWiki ? workspaceService.getMainWorkspace(workspace) : undefined; + const analyticsBaseProperties = { + storage: storageService, + commitOnly: storageService === SupportedStorageServices.local, + force, + }; if (isSubWiki && mainWorkspace === undefined) { logger.error(`Main workspace not found for sub workspace ${id}`, { function: 'syncWikiIfNeeded' }); return; } const idToUse = isSubWiki ? mainWorkspace!.id : id; - const { force = false } = options ?? {}; // we can only run filter on main wiki (tw don't know what is sub-wiki) // Skip draft check when user explicitly triggers sync (force=true), or when syncOnlyWhenNoDraft is disabled. if (!force && syncOnlyWhenNoDraft && !(await this.checkCanSyncDueToNoDraft(idToUse))) { await wikiService.wikiOperationInBrowser(WikiChannel.generalNotification, idToUse, [i18n.t('Preference.SyncOnlyWhenNoDraft')]); + void analyticsService.track('sync.failed', { + ...analyticsBaseProperties, + reason: 'draft_blocked', + }); return; } + void analyticsService.track('sync.triggered', analyticsBaseProperties); if (storageService === SupportedStorageServices.local) { // for local workspace, commitOnly, no sync and no force pull. - await gitService.commitAndSync(workspace, { dir: wikiFolderLocation, commitOnly: true, commitMessage: localCommitMessage }); + const hasChanges = await gitService.commitAndSync(workspace, { dir: wikiFolderLocation, commitOnly: true, commitMessage: localCommitMessage }); + void analyticsService.track('sync.completed', { + ...analyticsBaseProperties, + hasChanges, + }); } else if ( typeof gitUrl === 'string' && userInfo !== undefined @@ -98,6 +114,10 @@ export class Sync implements ISyncService { await workspaceViewService.restartWorkspaceViewService(id); } } + void analyticsService.track('sync.completed', { + ...analyticsBaseProperties, + hasChanges, + }); } else { // cloud workspace but missing gitUrl or userInfo - log and notify instead of silently doing nothing const reason = typeof gitUrl !== 'string' ? 'missing gitUrl' : 'missing userInfo (not authenticated)'; @@ -105,6 +125,10 @@ export class Sync implements ISyncService { await wikiService.wikiOperationInBrowser(WikiChannel.generalNotification, idToUse, [ `${i18n.t('Log.SynchronizationFailed')} (${reason})`, ]); + void analyticsService.track('sync.failed', { + ...analyticsBaseProperties, + reason: typeof gitUrl !== 'string' ? 'missing_git_url' : 'missing_user_info', + }); } } diff --git a/src/services/theme/index.ts b/src/services/theme/index.ts index 1ab0ee50..a0ec8b02 100644 --- a/src/services/theme/index.ts +++ b/src/services/theme/index.ts @@ -3,6 +3,7 @@ import { inject, injectable } from 'inversify'; import { BehaviorSubject } from 'rxjs'; import { WikiChannel } from '@/constants/channels'; +import type { IAnalyticsService } from '@services/analytics/interface'; import { container } from '@services/container'; import type { IPreferenceService } from '@services/preferences/interface'; import serviceIdentifier from '@services/serviceIdentifier'; @@ -58,6 +59,11 @@ export class ThemeService implements IThemeService { nativeTheme.themeSource = themeSource; await this.preferenceService.set('themeSource', themeSource); this.updateThemeSubject({ shouldUseDarkColors: this.shouldUseDarkColorsSync() }); + const analyticsService = container.get(serviceIdentifier.Analytics); + void analyticsService.track('theme.changed', { + themeSource, + darkMode: this.shouldUseDarkColorsSync(), + }); await this.updateActiveWikiTheme(); } diff --git a/src/services/updater/index.ts b/src/services/updater/index.ts index 9303c608..d525e368 100644 --- a/src/services/updater/index.ts +++ b/src/services/updater/index.ts @@ -5,6 +5,7 @@ import fetch from 'node-fetch'; import { BehaviorSubject } from 'rxjs'; import semver from 'semver'; +import type { IAnalyticsService } from '@services/analytics/interface'; import { container } from '@services/container'; import type { IContextService } from '@services/context/interface'; import { logger } from '@services/libs/log'; @@ -44,6 +45,7 @@ export class Updater implements IUpdaterService { public async checkForUpdates(): Promise { logger.debug('Checking for updates...'); this.setMetaData({ status: IUpdaterStatus.checkingForUpdate }); + const analyticsService = container.get(serviceIdentifier.Analytics); const menuService = container.get(serviceIdentifier.MenuService); await menuService.insertMenu('TidGi', [ { @@ -55,6 +57,7 @@ export class Updater implements IUpdaterService { let latestVersion: string; let latestReleasePageUrl: string; const allowPrerelease = await this.preferenceService.get('allowPrerelease'); + void analyticsService.track('updater.check_started', { allowPrerelease }); try { const latestReleaseData = await (allowPrerelease ? fetch('https://api.github.com/repos/tiddly-gittly/TidGi-Desktop/releases?per_page=1') @@ -70,6 +73,7 @@ export class Updater implements IUpdaterService { latestReleasePageUrl = latestReleaseData.html_url; } catch (fetchError) { logger.error('Fetching latest release failed', { fetchError }); + void analyticsService.track('updater.check_failed', { allowPrerelease }); this.setMetaData({ status: 'error' as IUpdaterStatus, info: { errorMessage: (fetchError as Error).message }, @@ -94,6 +98,7 @@ export class Updater implements IUpdaterService { const hasNewRelease = semver.gt(latestVersion, currentVersion); logger.debug('Compare version', { currentVersion, isLatestRelease: hasNewRelease }); if (hasNewRelease) { + void analyticsService.track('updater.update_available', { allowPrerelease }); this.setMetaData({ status: IUpdaterStatus.updateAvailable, info: { version: latestVersion, latestReleasePageUrl } }); const menuService = container.get(serviceIdentifier.MenuService); await menuService.insertMenu('TidGi', [ @@ -106,6 +111,7 @@ export class Updater implements IUpdaterService { }, ]); } else { + void analyticsService.track('updater.update_not_available', { allowPrerelease }); this.setMetaData({ status: IUpdaterStatus.updateNotAvailable, info: { version: latestVersion } }); const menuService = container.get(serviceIdentifier.MenuService); await menuService.insertMenu('TidGi', [ diff --git a/src/services/windows/index.ts b/src/services/windows/index.ts index 7b971de5..fe0f5e62 100644 --- a/src/services/windows/index.ts +++ b/src/services/windows/index.ts @@ -7,6 +7,7 @@ import serviceIdentifier from '@services/serviceIdentifier'; import { windowDimension, WindowMeta, WindowNames } from '@services/windows/WindowProperties'; import { Channels, MetaDataChannel, ViewChannel, WindowChannel } from '@/constants/channels'; +import type { IAnalyticsService } from '@services/analytics/interface'; import type { IPreferenceService } from '@services/preferences/interface'; import type { IViewService } from '@services/view/interface'; import type { IWorkspaceService } from '@services/workspaces/interface'; @@ -307,6 +308,12 @@ export class Window implements IWindowService { await workspaceViewService.refreshActiveWorkspaceView(); } } + // Track analytics event when preferences window is opened + if (windowName === WindowNames.preferences) { + const analyticsService = container.get(serviceIdentifier.Analytics); + void analyticsService.track('settings.opened', { window: 'preferences' }); + } + if (returnWindow === true) { return newWindow; } diff --git a/src/services/workspaces/index.ts b/src/services/workspaces/index.ts index 97f82faf..4c003efa 100644 --- a/src/services/workspaces/index.ts +++ b/src/services/workspaces/index.ts @@ -11,6 +11,7 @@ import { map } from 'rxjs/operators'; import { WikiChannel } from '@/constants/channels'; import { defaultCreatedPageTypes, PageType } from '@/constants/pageTypes'; import { getDefaultTidGiUrl } from '@/constants/urls'; +import type { IAnalyticsService } from '@services/analytics/interface'; import type { IAuthenticationService } from '@services/auth/interface'; import { container } from '@services/container'; import type { IDatabaseService } from '@services/database/interface'; @@ -595,6 +596,13 @@ export class Workspace implements IWorkspaceService { await this.set(newID, newWorkspace, true); logger.info(`[test-id-WORKSPACE_CREATED] Workspace created`, { workspaceId: newID, workspaceName: newWorkspace.name, wikiFolderLocation: newWorkspace.wikiFolderLocation }); + // Track workspace creation event + const analyticsService = container.get(serviceIdentifier.Analytics); + void analyticsService.track('workspace.created', { + isSubWiki: newWorkspace.isSubWiki ?? false, + hasGitUrl: Boolean(newWorkspace.gitUrl), + }); + return newWorkspace; } diff --git a/src/services/workspacesView/index.ts b/src/services/workspacesView/index.ts index 63f77e04..508909ac 100644 --- a/src/services/workspacesView/index.ts +++ b/src/services/workspacesView/index.ts @@ -4,6 +4,7 @@ import { inject, injectable } from 'inversify'; import { WikiChannel } from '@/constants/channels'; import { WikiCreationMethod } from '@/constants/wikiCreation'; +import type { IAnalyticsService } from '@services/analytics/interface'; import type { IAuthenticationService } from '@services/auth/interface'; import { container } from '@services/container'; import type { IContextService } from '@services/context/interface'; @@ -375,6 +376,12 @@ export class WorkspaceView implements IWorkspaceViewService { // later process will use the current active workspace await container.get(serviceIdentifier.Workspace).setActiveWorkspace(nextWorkspaceID, oldActiveWorkspace?.id); + // Track workspace activation event + const analyticsService = container.get(serviceIdentifier.Analytics); + void analyticsService.track('workspace.activated', { + isSubWiki: isWikiWorkspace(newWorkspace) ? (newWorkspace.isSubWiki ?? false) : false, + }); + // When coming from a page workspace (agent), the wiki that was active *before* the agent was // deferred and kept alive. Hibernate it now that we have a real wiki destination. // When coming from a real wiki directly, hibernate that wiki.