feat(analytics): instrument app lifecycle, sync, theme, updater, workspace events

This commit is contained in:
linonetwo 2026-04-29 18:10:37 +08:00
parent a35f96f6fd
commit 96466a050d
9 changed files with 110 additions and 5 deletions

View file

@ -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<IAnalyticsService>(serviceIdentifier.Analytics);
const contextService = container.get<IContextService>(serviceIdentifier.Context);
const databaseService = container.get<IDatabaseService>(serviceIdentifier.Database);
const preferenceService = container.get<IPreferenceService>(serviceIdentifier.Preference);
@ -223,6 +226,14 @@ const commonInit = async (): Promise<void> => {
}
// 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);

View file

@ -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<void> = async (requestUrl) => {
private readonly deepLinkHandler: (requestUrl: string, fromPendingQueue?: boolean) => Promise<void> = async (requestUrl, fromPendingQueue = false) => {
logger.info(`Receiving deep link`, { requestUrl, function: 'deepLinkHandler' });
const analyticsService = container.get<IAnalyticsService>(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);
}
}

View file

@ -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<IAnalyticsService>(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<INativeService>(serviceIdentifier.NativeService);

View file

@ -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<IWikiService>(serviceIdentifier.Wiki);
const gitService = container.get<IGitService>(serviceIdentifier.Git);
const analyticsService = container.get<IAnalyticsService>(serviceIdentifier.Analytics);
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
const workspaceViewService = container.get<IWorkspaceViewService>(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',
});
}
}

View file

@ -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<IAnalyticsService>(serviceIdentifier.Analytics);
void analyticsService.track('theme.changed', {
themeSource,
darkMode: this.shouldUseDarkColorsSync(),
});
await this.updateActiveWikiTheme();
}

View file

@ -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<void> {
logger.debug('Checking for updates...');
this.setMetaData({ status: IUpdaterStatus.checkingForUpdate });
const analyticsService = container.get<IAnalyticsService>(serviceIdentifier.Analytics);
const menuService = container.get<IMenuService>(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<IMenuService>(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<IMenuService>(serviceIdentifier.MenuService);
await menuService.insertMenu('TidGi', [

View file

@ -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<IAnalyticsService>(serviceIdentifier.Analytics);
void analyticsService.track('settings.opened', { window: 'preferences' });
}
if (returnWindow === true) {
return newWindow;
}

View file

@ -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<IAnalyticsService>(serviceIdentifier.Analytics);
void analyticsService.track('workspace.created', {
isSubWiki: newWorkspace.isSubWiki ?? false,
hasGitUrl: Boolean(newWorkspace.gitUrl),
});
return newWorkspace;
}

View file

@ -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<IWorkspaceService>(serviceIdentifier.Workspace).setActiveWorkspace(nextWorkspaceID, oldActiveWorkspace?.id);
// Track workspace activation event
const analyticsService = container.get<IAnalyticsService>(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.