mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2026-05-10 22:31:05 -07:00
feat(analytics): instrument app lifecycle, sync, theme, updater, workspace events
This commit is contained in:
parent
a35f96f6fd
commit
96466a050d
9 changed files with 110 additions and 5 deletions
34
src/main.ts
34
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<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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue