feat: implement telemetry system for application usage tracking
- Added telemetry utility to capture application events and metrics. - Integrated PostHog for event tracking with distinct user identification. - Implemented telemetry initialization, event capturing, and shutdown procedures. feat: add UV environment setup for Python management - Created utilities to manage Python installation and configuration. - Implemented network optimization checks for Python installation mirrors. - Added functions to set up managed Python environments with error handling. feat: enhance host API communication with token management - Introduced host API token retrieval and management for secure requests. - Updated host API fetch functions to include token in headers. - Added support for creating event sources with authentication. test: add comprehensive tests for gateway protocol and startup helpers - Implemented unit tests for gateway protocol helpers, event dispatching, and state management. - Added tests for startup recovery strategies and process policies. - Ensured coverage for connection monitoring and restart governance logic.
This commit is contained in:
56
electron/utils/env-path.ts
Normal file
56
electron/utils/env-path.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
type EnvMap = Record<string, string | undefined>;
|
||||
|
||||
function isPathKey(key: string): boolean {
|
||||
return key.toLowerCase() === 'path';
|
||||
}
|
||||
|
||||
function preferredPathKey(): string {
|
||||
return process.platform === 'win32' ? 'Path' : 'PATH';
|
||||
}
|
||||
|
||||
function pathDelimiter(): string {
|
||||
return process.platform === 'win32' ? ';' : ':';
|
||||
}
|
||||
|
||||
export function getPathEnvKey(env: EnvMap): string {
|
||||
const keys = Object.keys(env).filter(isPathKey);
|
||||
if (keys.length === 0) return preferredPathKey();
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
if (keys.includes('Path')) return 'Path';
|
||||
if (keys.includes('PATH')) return 'PATH';
|
||||
return keys[0];
|
||||
}
|
||||
|
||||
if (keys.includes('PATH')) return 'PATH';
|
||||
return keys[0];
|
||||
}
|
||||
|
||||
export function getPathEnvValue(env: EnvMap): string {
|
||||
const key = getPathEnvKey(env);
|
||||
return env[key] ?? '';
|
||||
}
|
||||
|
||||
export function setPathEnvValue(env: EnvMap, nextPath: string): EnvMap {
|
||||
const nextEnv: EnvMap = { ...env };
|
||||
for (const key of Object.keys(nextEnv)) {
|
||||
if (isPathKey(key)) {
|
||||
delete nextEnv[key];
|
||||
}
|
||||
}
|
||||
|
||||
nextEnv[getPathEnvKey(env)] = nextPath;
|
||||
return nextEnv;
|
||||
}
|
||||
|
||||
export function prependPathEntry(
|
||||
env: EnvMap,
|
||||
entry: string,
|
||||
): { env: EnvMap; path: string } {
|
||||
const current = getPathEnvValue(env);
|
||||
const nextPath = current ? `${entry}${pathDelimiter()}${current}` : entry;
|
||||
return {
|
||||
env: setPathEnvValue(env, nextPath),
|
||||
path: nextPath,
|
||||
};
|
||||
}
|
||||
@@ -72,6 +72,22 @@ export function getOpenClawBuildDir(): string {
|
||||
return join(app.getAppPath(), 'build', OPENCLAW_PACKAGE_DIR_NAME);
|
||||
}
|
||||
|
||||
export function normalizeNodeRequirePathForNodeOptions(modulePath: string): string {
|
||||
if (process.platform !== 'win32') {
|
||||
return modulePath;
|
||||
}
|
||||
|
||||
return modulePath.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
export function appendNodeRequireToNodeOptions(
|
||||
nodeOptions: string | undefined,
|
||||
modulePath: string,
|
||||
): string {
|
||||
const normalizedPath = normalizeNodeRequirePathForNodeOptions(modulePath);
|
||||
return `${nodeOptions ?? ''} --require "${normalizedPath}"`.trim();
|
||||
}
|
||||
|
||||
export function getOpenClawPackageStatus(): {
|
||||
dir: string;
|
||||
entryPath: string;
|
||||
|
||||
116
electron/utils/telemetry.ts
Normal file
116
electron/utils/telemetry.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { app } from 'electron';
|
||||
import axios from 'axios';
|
||||
import logManager from '@electron/service/logger';
|
||||
import configManager from '@electron/service/config-service';
|
||||
|
||||
const POSTHOG_API_KEY = 'phc_aGNegeJQP5FzNiF2rEoKqQbkuCpiiETMttplibXpB0n';
|
||||
const POSTHOG_HOST = 'https://us.i.posthog.com';
|
||||
const TELEMETRY_REQUEST_TIMEOUT_MS = 2_500;
|
||||
const TELEMETRY_SHUTDOWN_TIMEOUT_MS = 1_500;
|
||||
|
||||
let telemetryEnabled = false;
|
||||
let distinctId = '';
|
||||
const pendingCaptures = new Set<Promise<void>>();
|
||||
|
||||
function getCommonProperties(): Record<string, unknown> {
|
||||
return {
|
||||
$app_version: app.getVersion(),
|
||||
$os: process.platform,
|
||||
os_tag: process.platform,
|
||||
arch: process.arch,
|
||||
};
|
||||
}
|
||||
|
||||
function queueCapture(event: string, properties: Record<string, unknown>): void {
|
||||
let capturePromise: Promise<void>;
|
||||
const request = axios.post(
|
||||
`${POSTHOG_HOST}/capture/`,
|
||||
{
|
||||
api_key: POSTHOG_API_KEY,
|
||||
event,
|
||||
properties: {
|
||||
distinct_id: distinctId,
|
||||
...properties,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: TELEMETRY_REQUEST_TIMEOUT_MS,
|
||||
validateStatus: () => true,
|
||||
},
|
||||
).then((response) => {
|
||||
if (response.status >= 400) {
|
||||
logManager.debug(`Telemetry backend rejected event "${event}" with status ${response.status}`);
|
||||
}
|
||||
}).catch((error) => {
|
||||
logManager.debug(`Failed to capture telemetry event "${event}":`, error);
|
||||
}).finally(() => {
|
||||
pendingCaptures.delete(capturePromise);
|
||||
});
|
||||
|
||||
capturePromise = request.then(() => {});
|
||||
pendingCaptures.add(capturePromise);
|
||||
}
|
||||
|
||||
export async function initTelemetry(): Promise<void> {
|
||||
telemetryEnabled = Boolean(configManager.get('telemetryEnabled' as never));
|
||||
if (!telemetryEnabled) {
|
||||
logManager.info('Telemetry is disabled; observability stays local-only');
|
||||
return;
|
||||
}
|
||||
|
||||
const storedDistinctId = configManager.get<string | null>('machineId' as never);
|
||||
distinctId = storedDistinctId && storedDistinctId.trim()
|
||||
? storedDistinctId
|
||||
: randomUUID();
|
||||
if (!storedDistinctId) {
|
||||
configManager.set('machineId' as never, distinctId);
|
||||
}
|
||||
|
||||
const hasReportedInstall = Boolean(configManager.get('hasReportedInstall' as never));
|
||||
if (!hasReportedInstall) {
|
||||
captureTelemetryEvent('app_installed');
|
||||
configManager.set('hasReportedInstall' as never, true);
|
||||
}
|
||||
captureTelemetryEvent('app_opened');
|
||||
}
|
||||
|
||||
export function trackMetric(event: string, properties: Record<string, unknown> = {}): void {
|
||||
logManager.info(`[metric] ${event}`, properties);
|
||||
}
|
||||
|
||||
export function captureTelemetryEvent(
|
||||
event: string,
|
||||
properties: Record<string, unknown> = {},
|
||||
): void {
|
||||
if (!telemetryEnabled || !distinctId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedProperties = {
|
||||
...getCommonProperties(),
|
||||
...properties,
|
||||
};
|
||||
queueCapture(event, mergedProperties);
|
||||
}
|
||||
|
||||
export async function shutdownTelemetry(): Promise<void> {
|
||||
telemetryEnabled = false;
|
||||
if (pendingCaptures.size === 0) {
|
||||
distinctId = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const captures = Array.from(pendingCaptures);
|
||||
await Promise.race([
|
||||
Promise.allSettled(captures).then(() => undefined),
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, TELEMETRY_SHUTDOWN_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
|
||||
distinctId = '';
|
||||
}
|
||||
123
electron/utils/uv-env.ts
Normal file
123
electron/utils/uv-env.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { request } from 'node:https';
|
||||
import { app } from 'electron';
|
||||
import logManager from '@electron/service/logger';
|
||||
|
||||
const UV_MIRROR_ENV: Record<string, string> = {
|
||||
UV_PYTHON_INSTALL_MIRROR: 'https://registry.npmmirror.com/-/binary/python-build-standalone/',
|
||||
UV_INDEX_URL: 'https://pypi.tuna.tsinghua.edu.cn/simple/',
|
||||
};
|
||||
|
||||
const GOOGLE_204_HOST = 'www.google.com';
|
||||
const GOOGLE_204_PATH = '/generate_204';
|
||||
const GOOGLE_204_TIMEOUT_MS = 2000;
|
||||
|
||||
let cachedOptimized: boolean | null = null;
|
||||
let cachedPromise: Promise<boolean> | null = null;
|
||||
let loggedOnce = false;
|
||||
|
||||
function getLocaleAndTimezone(): { locale: string; timezone: string } {
|
||||
const locale = app.getLocale?.() || '';
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||
return { locale, timezone };
|
||||
}
|
||||
|
||||
function isRegionOptimized(locale: string, timezone: string): boolean {
|
||||
if (timezone) return timezone === 'Asia/Shanghai';
|
||||
return locale === 'zh-CN';
|
||||
}
|
||||
|
||||
function probeGoogle204(timeoutMs: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (value: boolean) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const req = request(
|
||||
{
|
||||
method: 'GET',
|
||||
hostname: GOOGLE_204_HOST,
|
||||
path: GOOGLE_204_PATH,
|
||||
},
|
||||
(res) => {
|
||||
const status = res.statusCode || 0;
|
||||
res.resume();
|
||||
finish(status >= 200 && status < 300);
|
||||
},
|
||||
);
|
||||
|
||||
req.setTimeout(timeoutMs, () => {
|
||||
req.destroy(new Error('google_204_timeout'));
|
||||
});
|
||||
|
||||
req.on('error', () => finish(false));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function computeOptimization(): Promise<boolean> {
|
||||
const { locale, timezone } = getLocaleAndTimezone();
|
||||
|
||||
if (isRegionOptimized(locale, timezone)) {
|
||||
if (!loggedOnce) {
|
||||
logManager.info(
|
||||
`Region optimization enabled via locale/timezone (locale=${locale || 'unknown'}, tz=${timezone || 'unknown'})`,
|
||||
);
|
||||
loggedOnce = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const reachable = await probeGoogle204(GOOGLE_204_TIMEOUT_MS);
|
||||
const isOptimized = !reachable;
|
||||
|
||||
if (!loggedOnce) {
|
||||
const reason = reachable ? 'google_204_reachable' : 'google_204_unreachable';
|
||||
logManager.info(
|
||||
`Network optimization probe: ${reason} (locale=${locale || 'unknown'}, tz=${timezone || 'unknown'})`,
|
||||
);
|
||||
loggedOnce = true;
|
||||
}
|
||||
|
||||
return isOptimized;
|
||||
}
|
||||
|
||||
export async function shouldOptimizeNetwork(): Promise<boolean> {
|
||||
if (cachedOptimized !== null) return cachedOptimized;
|
||||
if (cachedPromise) return cachedPromise;
|
||||
|
||||
if (!app.isReady()) {
|
||||
await app.whenReady();
|
||||
}
|
||||
|
||||
cachedPromise = computeOptimization()
|
||||
.then((result) => {
|
||||
cachedOptimized = result;
|
||||
return result;
|
||||
})
|
||||
.catch((error) => {
|
||||
logManager.warn('Network optimization check failed, defaulting to enabled:', error);
|
||||
cachedOptimized = true;
|
||||
return true;
|
||||
})
|
||||
.finally(() => {
|
||||
cachedPromise = null;
|
||||
});
|
||||
|
||||
return cachedPromise;
|
||||
}
|
||||
|
||||
export async function getUvMirrorEnv(): Promise<Record<string, string>> {
|
||||
const isOptimized = await shouldOptimizeNetwork();
|
||||
return isOptimized ? { ...UV_MIRROR_ENV } : {};
|
||||
}
|
||||
|
||||
export async function warmupNetworkOptimization(): Promise<void> {
|
||||
try {
|
||||
await shouldOptimizeNetwork();
|
||||
} catch {
|
||||
// Ignore warmup failures.
|
||||
}
|
||||
}
|
||||
192
electron/utils/uv-setup.ts
Normal file
192
electron/utils/uv-setup.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { execSync, spawn } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { app } from 'electron';
|
||||
import logManager from '@electron/service/logger';
|
||||
import { getUvMirrorEnv } from './uv-env';
|
||||
|
||||
function getBundledUvPath(): string {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
const target = `${platform}-${arch}`;
|
||||
const binName = platform === 'win32' ? 'uv.exe' : 'uv';
|
||||
|
||||
if (app.isPackaged) {
|
||||
return join(process.resourcesPath, 'bin', binName);
|
||||
}
|
||||
|
||||
return join(process.cwd(), 'resources', 'bin', target, binName);
|
||||
}
|
||||
|
||||
function findUvInPathSync(): boolean {
|
||||
try {
|
||||
const command = process.platform === 'win32' ? 'where.exe uv' : 'which uv';
|
||||
execSync(command, { stdio: 'ignore', timeout: 5000, windowsHide: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUvBin(): { bin: string; source: 'bundled' | 'path' | 'bundled-fallback' } {
|
||||
const bundled = getBundledUvPath();
|
||||
|
||||
if (app.isPackaged) {
|
||||
if (existsSync(bundled)) {
|
||||
return { bin: bundled, source: 'bundled' };
|
||||
}
|
||||
logManager.warn(`Bundled uv binary not found at ${bundled}, falling back to system PATH`);
|
||||
}
|
||||
|
||||
if (findUvInPathSync()) {
|
||||
return { bin: 'uv', source: 'path' };
|
||||
}
|
||||
|
||||
if (existsSync(bundled)) {
|
||||
return { bin: bundled, source: 'bundled-fallback' };
|
||||
}
|
||||
|
||||
return { bin: 'uv', source: 'path' };
|
||||
}
|
||||
|
||||
export async function checkUvInstalled(): Promise<boolean> {
|
||||
const { bin, source } = resolveUvBin();
|
||||
if (source === 'bundled' || source === 'bundled-fallback') {
|
||||
return existsSync(bin);
|
||||
}
|
||||
return findUvInPathSync();
|
||||
}
|
||||
|
||||
export async function installUv(): Promise<void> {
|
||||
const isAvailable = await checkUvInstalled();
|
||||
if (!isAvailable) {
|
||||
const bin = getBundledUvPath();
|
||||
throw new Error(`uv not found in system PATH and bundled binary missing at ${bin}`);
|
||||
}
|
||||
logManager.info('uv is available and ready to use');
|
||||
}
|
||||
|
||||
export async function isPythonReady(): Promise<boolean> {
|
||||
const { bin: uvBin } = resolveUvBin();
|
||||
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
try {
|
||||
const child = spawn(uvBin, ['python', 'find', '3.12'], {
|
||||
windowsHide: true,
|
||||
});
|
||||
child.on('close', (code) => resolve(code === 0));
|
||||
child.on('error', () => resolve(false));
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runPythonInstall(
|
||||
uvBin: string,
|
||||
env: Record<string, string | undefined>,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const stderrChunks: string[] = [];
|
||||
const stdoutChunks: string[] = [];
|
||||
|
||||
const child = spawn(uvBin, ['python', 'install', '3.12'], {
|
||||
env,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
const line = data.toString().trim();
|
||||
if (line) {
|
||||
stdoutChunks.push(line);
|
||||
logManager.debug(`[python-setup:${label}] stdout: ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
const line = data.toString().trim();
|
||||
if (line) {
|
||||
stderrChunks.push(line);
|
||||
logManager.info(`[python-setup:${label}] stderr: ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const stderr = stderrChunks.join('\n');
|
||||
const stdout = stdoutChunks.join('\n');
|
||||
const detail = stderr || stdout || '(no output captured)';
|
||||
reject(new Error(
|
||||
`Python installation failed with code ${code} [${label}]\n` +
|
||||
` uv binary: ${uvBin}\n` +
|
||||
` platform: ${process.platform}/${process.arch}\n` +
|
||||
` output: ${detail}`,
|
||||
));
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
reject(new Error(
|
||||
`Python installation spawn error [${label}]: ${error.message}\n` +
|
||||
` uv binary: ${uvBin}\n` +
|
||||
` platform: ${process.platform}/${process.arch}`,
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function setupManagedPython(): Promise<void> {
|
||||
const { bin: uvBin, source } = resolveUvBin();
|
||||
const uvEnv = await getUvMirrorEnv();
|
||||
const hasMirror = Object.keys(uvEnv).length > 0;
|
||||
|
||||
logManager.info(
|
||||
`Setting up managed Python 3.12 ` +
|
||||
`(uv=${uvBin}, source=${source}, arch=${process.arch}, mirror=${hasMirror})`,
|
||||
);
|
||||
|
||||
const baseEnv: Record<string, string | undefined> = { ...process.env };
|
||||
|
||||
try {
|
||||
await runPythonInstall(uvBin, { ...baseEnv, ...uvEnv }, hasMirror ? 'mirror' : 'default');
|
||||
} catch (firstError) {
|
||||
logManager.warn('Python install attempt 1 failed:', firstError);
|
||||
|
||||
if (!hasMirror) {
|
||||
throw firstError;
|
||||
}
|
||||
|
||||
logManager.info('Retrying Python install without mirror...');
|
||||
try {
|
||||
await runPythonInstall(uvBin, baseEnv, 'no-mirror');
|
||||
} catch (secondError) {
|
||||
logManager.error('Python install attempt 2 (no mirror) also failed:', secondError);
|
||||
throw secondError;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const findPath = await new Promise<string>((resolve) => {
|
||||
const child = spawn(uvBin, ['python', 'find', '3.12'], {
|
||||
env: { ...process.env, ...uvEnv },
|
||||
windowsHide: true,
|
||||
});
|
||||
let output = '';
|
||||
child.stdout?.on('data', (data) => {
|
||||
output += data;
|
||||
});
|
||||
child.on('close', () => resolve(output.trim()));
|
||||
child.on('error', () => resolve(''));
|
||||
});
|
||||
|
||||
if (findPath) {
|
||||
logManager.info(`Managed Python 3.12 installed at: ${findPath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logManager.warn('Could not determine Python path after install:', error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user