feat(analytics): stable device UUID for user identity, default host analytics.tidgi.fun

- Add deviceId field to IAnalyticsSecretSettings; generated once via
  crypto.randomUUID() and persisted to analyticsSecrets on first use
- Inject deviceId as user_id in every track payload so Rybbit groups
  all events from the same installation under one identified_user_id,
  independent of IP or User-Agent changes (proxy, network switch, etc.)
- Set default analyticsHost to https://analytics.tidgi.fun so
  installations without explicit env-var override point at the right server
This commit is contained in:
lin onetwo 2026-05-01 22:05:11 +08:00
parent c82be64705
commit d9ae611e01
2 changed files with 29 additions and 1 deletions

View file

@ -1,5 +1,6 @@
import { app } from 'electron';
import { inject, injectable } from 'inversify';
import { randomUUID } from 'node:crypto';
import { container } from '@services/container';
import type { IDatabaseService, ISettingFile } from '@services/database/interface';
@ -11,6 +12,12 @@ import type { AnalyticsEventName, BuiltInAnalyticsEventName, IAnalyticsEventProp
interface IAnalyticsSecretSettings {
deviceFirstLaunchDate?: string;
deviceLastLaunchDate?: string;
/**
* Stable random UUID generated once on first launch and persisted forever.
* Used as Rybbit `user_id` so events from the same installation are always
* grouped under the same user regardless of IP or User-Agent changes.
*/
deviceId?: string;
}
interface ITrackPayload {
@ -20,6 +27,8 @@ interface ITrackPayload {
properties?: Record<string, string | number | boolean>;
hostname: string;
pathname: string;
/** Stable per-installation UUID — maps to Rybbit identified_user_id */
user_id?: string;
}
const ANALYTICS_SETTINGS_KEY = 'analyticsSecrets';
@ -266,6 +275,8 @@ export class AnalyticsService implements IAnalyticsService {
return undefined;
}
const deviceId = this.getOrCreateDeviceId();
return {
site_id: analyticsSiteId.trim(),
type: 'custom_event',
@ -273,10 +284,27 @@ export class AnalyticsService implements IAnalyticsService {
properties,
hostname: this.getAnalyticsHostname(analyticsHost),
pathname: ANALYTICS_PATHNAME,
user_id: deviceId,
};
});
}
/**
* Return the persisted device UUID, creating and storing it on first call.
* Stored alongside other analytics secrets so it survives app updates.
*/
private getOrCreateDeviceId(): string {
const databaseService = container.get<IDatabaseService>(serviceIdentifier.Database);
const secrets = this.getAnalyticsSecrets(databaseService);
if (secrets.deviceId) {
return secrets.deviceId;
}
const newId = randomUUID();
databaseService.setSetting(ANALYTICS_SETTINGS_KEY as keyof ISettingFile, { ...secrets, deviceId: newId } as never);
void databaseService.immediatelyStoreSettingsToFile();
return newId;
}
private getAnalyticsTrackUrl(analyticsHost: string): string {
const normalizedHost = analyticsHost.trim().replace(/\/+$/, '');
return normalizedHost.endsWith('/api') ? `${normalizedHost}/track` : `${normalizedHost}/api/track`;

View file

@ -15,7 +15,7 @@ function getAnalyticsEnvironmentOverrides(): { analyticsApiKey: string; analytic
analyticsSiteId: process.env.TIDGI_ANALYTICS_SITE_ID ?? 'test-site',
};
}
return { analyticsApiKey: '', analyticsEnabled: true, analyticsHost: '', analyticsSiteId: '' };
return { analyticsApiKey: '', analyticsEnabled: true, analyticsHost: 'https://analytics.tidgi.fun', analyticsSiteId: '' };
}
const analyticsEnvironment = getAnalyticsEnvironmentOverrides();