diff --git a/xmcl-electron-app/main/definedPlugins.ts b/xmcl-electron-app/main/definedPlugins.ts index 89b0d6eb1..3146dd9b5 100644 --- a/xmcl-electron-app/main/definedPlugins.ts +++ b/xmcl-electron-app/main/definedPlugins.ts @@ -28,6 +28,7 @@ import { pluginOfficialUserApi } from '@xmcl/runtime/user/pluginOfficialUserApi' import { pluginOffineUser } from '@xmcl/runtime/user/pluginOfflineUser' import { pluginUserTokenStorage } from '@xmcl/runtime/user/pluginUserTokenStorage' import { pluginYggdrasilHandler } from '@xmcl/runtime/yggdrasilServer/pluginYggdrasilHandler' +import { pluginLaunchPrecheck } from '@xmcl/runtime/launch/pluginLaunchPrecheck' import { LauncherAppPlugin } from '~/app' import { definedServices } from './definedServices' @@ -41,6 +42,7 @@ export const definedPlugins: LauncherAppPlugin[] = [ pluginResourceWorker, pluginEncodingWorker, pluginSetupWorker, + pluginLaunchPrecheck, pluginMediaProtocol, pluginResourcePackLink, diff --git a/xmcl-keystone-ui/src/composables/instanceLaunch.ts b/xmcl-keystone-ui/src/composables/instanceLaunch.ts index a2a686a67..bd4dcf5f7 100644 --- a/xmcl-keystone-ui/src/composables/instanceLaunch.ts +++ b/xmcl-keystone-ui/src/composables/instanceLaunch.ts @@ -9,7 +9,7 @@ export const kInstanceLaunch: InjectionKey> export function useInstanceLaunch(instance: Ref, resolvedVersion: Ref } | undefined>, java: Ref, userProfile: Ref, globalState: ReturnType) { const { refreshUser } = useService(UserServiceKey) - const { launch, kill, on, getGameProcesses, reportLaunchStatus } = useService(LaunchServiceKey) + const { launch, kill, on, getGameProcesses, reportOperation } = useService(LaunchServiceKey) const { globalAssignMemory, globalMaxMemory, globalMinMemory, globalMcOptions, globalVmOptions, globalFastLaunch, globalHideLauncher, globalShowLog } = useGlobalSettings(globalState) const { getMemoryStatus } = useService(BaseServiceKey) const { abortRefresh } = useService(UserServiceKey) @@ -55,7 +55,33 @@ export function useInstanceLaunch(instance: Ref, resolvedVersion: Ref< data.value = data.value?.filter(p => p.pid !== pid) }) - async function generateLaunchOptions() { + async function track(p: Promise, name: string, id: string) { + const start = performance.now() + reportOperation({ + name, + operationId: id, + }) + try { + const v = await p + reportOperation({ + duration: performance.now() - start, + name, + operationId: id, + success: true, + }) + return v + } catch (e) { + reportOperation({ + duration: performance.now() - start, + name, + operationId: id, + success: false, + }) + throw e + } + } + + async function generateLaunchOptions(id: string) { const ver = resolvedVersion.value if (!ver || 'requirements' in ver) { throw new LaunchException({ type: 'launchNoVersionInstalled' }) @@ -71,7 +97,7 @@ export function useInstanceLaunch(instance: Ref, resolvedVersion: Ref< if (authority && (authority.protocol === 'http:' || authority?.protocol === 'https:' || userProfile.value.authority === AUTHORITY_DEV)) { launchingStatus.value = 'preparing-authlib' yggdrasilAgent = { - jar: await getOrInstallAuthlibInjector(), + jar: await track(getOrInstallAuthlibInjector(), 'prepare-authlib', id), server: userProfile.value.authority, } } @@ -88,7 +114,7 @@ export function useInstanceLaunch(instance: Ref, resolvedVersion: Ref< // noop } else if (assignMemory === 'auto') { launchingStatus.value = 'assigning-memory' - const mem = await getMemoryStatus() + const mem = await track(getMemoryStatus(), 'get-memory-status', id) minMemory = Math.floor(mem.free / 1024 / 1024 - 256) } else { minMemory = undefined @@ -99,6 +125,7 @@ export function useInstanceLaunch(instance: Ref, resolvedVersion: Ref< const mcOptions = inst.mcOptions ?? globalMcOptions.value.filter(v => !!v) const options: LaunchOptions = { + operationId: id, version: instance.value.version || ver.id, gameDirectory: instance.value.path, user: userProfile.value, @@ -118,15 +145,16 @@ export function useInstanceLaunch(instance: Ref, resolvedVersion: Ref< async function launchGame() { try { error.value = undefined - const options = await generateLaunchOptions() + const operationId = crypto.getRandomValues(new Uint32Array(1))[0].toString(16) + const options = await generateLaunchOptions(operationId) if (!options.skipAssetsCheck) { launchingStatus.value = 'refreshing-user' try { - await Promise.race([ - new Promise((resolve) => { setTimeout(resolve, 5_000) }), + await track(Promise.race([ + new Promise((resolve, reject) => { setTimeout(() => reject(new Error('Timeout')), 5_000) }), refreshUser(userProfile.value.id), - ]) + ]), 'refresh-user', operationId) } catch (e) { } } @@ -162,28 +190,6 @@ export function useInstanceLaunch(instance: Ref, resolvedVersion: Ref< } } - let last = 0 - const record = {} as Record - let timeout: any - watch(launchingStatus, (newVal, oldVal) => { - if (oldVal !== '') { - const duration = performance.now() - last - record[oldVal] = duration - record[newVal] = -1 - if (!newVal) { - reportLaunchStatus(record) - clearTimeout(timeout) - } - } else { - // start timming - last = performance.now() - record[newVal] = -1 - timeout = setTimeout(() => { - reportLaunchStatus(record, 30_000) - }, 30_000) - } - }) - return { launch: launchGame, kill: killGame, diff --git a/xmcl-runtime-api/src/services/LaunchService.ts b/xmcl-runtime-api/src/services/LaunchService.ts index c0cc2cc66..dd59ef31b 100644 --- a/xmcl-runtime-api/src/services/LaunchService.ts +++ b/xmcl-runtime-api/src/services/LaunchService.ts @@ -17,11 +17,16 @@ interface LaunchServiceEventMap { 'minecraft-exit': LaunchOptions & { pid: number; code?: number; signal?: string; duration: number; crashReport?: string; crashReportLocation?: string; errorLog: string } 'minecraft-stdout': { pid: number; stdout: string } 'minecraft-stderr': { pid: number; stdout: string } - 'minecraft-launch-status-pre': { record: Record; alreadyTimeout?: string } + 'launch-performance-pre': { id: string; name: string } + 'launch-performance': { id: string; name: string; duration: number } 'error': LaunchException } export interface LaunchOptions { + /** + * The operation id for telemery + */ + operationId?: string /** * Override selected version for current instance */ @@ -108,6 +113,20 @@ export interface GameProcess { options: LaunchOptions } +export interface ReportOperationPayload { + operationId: string + /** + * Name of the operation + */ + name: string + /** + * The duration of the operation. If empty, it means the operation is just started + */ + duration?: number + + success?: boolean +} + export interface LaunchService extends GenericEventEmitter { /** * Generate useable launch arguments for current profile @@ -132,8 +151,10 @@ export interface LaunchService extends GenericEventEmitter - - reportLaunchStatus(record: Record, alreadyTimeout?: number): Promise + /** + * Only used for telemetry + */ + reportOperation(options: ReportOperationPayload): Promise } export type LaunchExceptions = { diff --git a/xmcl-runtime/launch/LaunchMiddleware.ts b/xmcl-runtime/launch/LaunchMiddleware.ts index aa4d7d7e2..ee6fc31af 100644 --- a/xmcl-runtime/launch/LaunchMiddleware.ts +++ b/xmcl-runtime/launch/LaunchMiddleware.ts @@ -1,24 +1,25 @@ -import { LaunchOption as ResolvedLaunchOptions } from '@xmcl/core' +import { LaunchOption as ResolvedLaunchOptions, ResolvedVersion } from '@xmcl/core' import { LaunchOptions } from '@xmcl/runtime-api' export interface LaunchMiddleware { - onBeforeLaunch(input: LaunchOptions, output: ResolvedLaunchOptions, context: Record): Promise + name: string + onBeforeLaunch(input: LaunchOptions, output: ResolvedLaunchOptions & { version: ResolvedVersion }, context: Record): Promise onAfterLaunch?(result: { /** - * The code of the process exit. This is the nodejs child process "exit" event arg. - */ + * The code of the process exit. This is the nodejs child process "exit" event arg. + */ code: number /** - * The signal of the process exit. This is the nodejs child process "exit" event arg. - */ + * The signal of the process exit. This is the nodejs child process "exit" event arg. + */ signal: string /** - * The crash report content - */ + * The crash report content + */ crashReport: string /** - * The location of the crash report - */ + * The location of the crash report + */ crashReportLocation: string }, output: ResolvedLaunchOptions, context: Record): void } diff --git a/xmcl-runtime/launch/LaunchService.ts b/xmcl-runtime/launch/LaunchService.ts index 43f0676c7..9d8f88568 100644 --- a/xmcl-runtime/launch/LaunchService.ts +++ b/xmcl-runtime/launch/LaunchService.ts @@ -1,11 +1,10 @@ -import { LaunchPrecheck, MinecraftFolder, LaunchOption as ResolvedLaunchOptions, ResolvedVersion, Version, createMinecraftProcessWatcher, diagnoseJar, diagnoseLibraries, generateArguments, launch } from '@xmcl/core' -import { AUTHORITY_DEV, GameProcess, LaunchService as ILaunchService, LaunchException, LaunchOptions, LaunchServiceKey } from '@xmcl/runtime-api' +import { MinecraftFolder, LaunchOption as ResolvedLaunchOptions, ResolvedVersion, Version, createMinecraftProcessWatcher, generateArguments, launch } from '@xmcl/core' +import { AUTHORITY_DEV, GameProcess, LaunchService as ILaunchService, LaunchException, LaunchOptions, LaunchServiceKey, ReportOperationPayload } from '@xmcl/runtime-api' import { ChildProcess } from 'child_process' +import { randomUUID } from 'crypto' import { EOL } from 'os' import { Inject, LauncherAppKey, PathResolver, kGameDataPath } from '~/app' import { EncodingWorker, kEncodingWorker } from '~/encoding' -import { InstallService } from '~/install' -import { JavaService, JavaValidation } from '~/java' import { AbstractService, ExposeServiceKey } from '~/service' import { UserTokenStorage, kUserTokenStorage } from '~/user' import { LauncherApp } from '../app/LauncherApp' @@ -16,11 +15,9 @@ import { LaunchMiddleware } from './LaunchMiddleware' export class LaunchService extends AbstractService implements ILaunchService { private processes: Record = {} - private plugins: LaunchMiddleware[] = [] + private middlewares: LaunchMiddleware[] = [] constructor(@Inject(LauncherAppKey) app: LauncherApp, - @Inject(InstallService) private installService: InstallService, - @Inject(JavaService) private javaService: JavaService, @Inject(kGameDataPath) private getPath: PathResolver, @Inject(kUserTokenStorage) private userTokenStorage: UserTokenStorage, @Inject(kEncodingWorker) private encoder: EncodingWorker, @@ -29,7 +26,7 @@ export class LaunchService extends AbstractService implements ILaunchService { } registerMiddleware(plugin: LaunchMiddleware) { - this.plugins.push(plugin) + this.middlewares.push(plugin) } getProcesses(): number[] { @@ -46,12 +43,11 @@ export class LaunchService extends AbstractService implements ILaunchService { const minMemory: number | undefined = options.maxMemory const maxMemory: number | undefined = options.minMemory - const prechecks = [LaunchPrecheck.checkNatives, LaunchPrecheck.linkAssets] /** * Build launch condition */ - const launchOptions: ResolvedLaunchOptions = { + const launchOptions: ResolvedLaunchOptions & { version: ResolvedVersion } = { gameProfile, accessToken, properties: {}, @@ -70,7 +66,7 @@ export class LaunchService extends AbstractService implements ILaunchService { launcherBrand: options?.launcherBrand ?? '', launcherName: options?.launcherName ?? 'XMCL', yggdrasilAgent, - prechecks, + prechecks: [], } const getAddress = () => { @@ -141,6 +137,19 @@ export class LaunchService extends AbstractService implements ILaunchService { } } + async #track(promise: Promise, name: string, id: string): Promise { + const start = performance.now() + this.emit('launch-performance-pre', { id, name }) + try { + const result = await promise + this.emit('launch-performance', { id, name, duration: performance.now() - start, success: true }) + return result + } catch (e) { + this.emit('launch-performance', { id, name, duration: performance.now() - start, success: false }) + throw e + } + } + /** * Launch the current selected instance. This will return a boolean promise indeicate whether launch is success. * @returns Does this launch request success? @@ -151,11 +160,12 @@ export class LaunchService extends AbstractService implements ILaunchService { const javaPath = options.java let version: ResolvedVersion | undefined + const operationId = options.operationId || randomUUID() if (options.version) { this.log(`Override the version: ${options.version}`) try { - version = await Version.parse(this.getPath(), options.version) + version = await this.#track(Version.parse(this.getPath(), options.version), 'parse-version', operationId) } catch (e) { this.warn(`Cannot use override version: ${options.version}`) this.warn(e) @@ -169,53 +179,24 @@ export class LaunchService extends AbstractService implements ILaunchService { }) } - if (!options?.skipAssetsCheck) { - const resolvedVersion = version - const resourceFolder = new MinecraftFolder(this.getPath()) - await Promise.all([ - diagnoseJar(resolvedVersion, resourceFolder).then((issue) => { - if (issue) { - return this.installService.installMinecraftJar(resolvedVersion) - } - }), - diagnoseLibraries(version, resourceFolder).then(async (libs) => { - if (libs.length > 0) { - await this.installService.installLibraries(libs.map(l => l.library)) - } - }), - ]) - } - this.log(`Will launch with ${version.id} version.`) if (!javaPath) { throw new LaunchException({ type: 'launchNoProperJava', javaPath: javaPath || '' }, 'Cannot launch without a valid java') } - const accessToken = user ? await this.userTokenStorage.get(user).catch(() => undefined) : undefined + const accessToken = user ? await this.#track(this.userTokenStorage.get(user).catch(() => undefined), 'get-user-token', operationId) : undefined const launchOptions = this.#generateOptions(options, version, accessToken) const context = {} - for (const plugin of this.plugins) { + for (const plugin of this.middlewares) { try { - await plugin.onBeforeLaunch(options, launchOptions, context) + await this.#track(plugin.onBeforeLaunch(options, launchOptions, context), plugin.name, operationId) } catch (e) { this.warn('Fail to run plugin') this.error(e as any) } } - try { - const result = await this.javaService.validateJavaPath(javaPath) - if (result === JavaValidation.NotExisted) { - throw new LaunchException({ type: 'launchInvalidJavaPath', javaPath }) - } - if (result === JavaValidation.NoPermission) { - throw new LaunchException({ type: 'launchJavaNoPermission', javaPath }) - } - } catch (e) { - throw new LaunchException({ type: 'launchNoProperJava', javaPath }, 'Cannot launch without a valid java', { cause: e }) - } - if (launchOptions.server) { this.log('Launching a server') } @@ -234,7 +215,7 @@ export class LaunchService extends AbstractService implements ILaunchService { } // Launch - const process = await launch(launchOptions) + const process = await this.#track(launch(launchOptions), 'launch', operationId) const processData = { pid: process.pid!, options, @@ -286,7 +267,7 @@ export class LaunchService extends AbstractService implements ILaunchService { crashReportLocation = crashReportLocation.substring(0, crashReportLocation.lastIndexOf('.txt') + 4) } Promise.all(errPromises).catch((e) => { this.error(e) }).finally(() => { - for (const plugin of this.plugins) { + for (const plugin of this.middlewares) { try { plugin.onAfterLaunch?.({ code, signal, crashReport, crashReportLocation }, launchOptions, context) } catch (e) { @@ -348,7 +329,19 @@ export class LaunchService extends AbstractService implements ILaunchService { })) } - async reportLaunchStatus(record: Record, alreadyTimeout?: number | undefined): Promise { - this.emit('minecraft-launch-status-pre', { record, alreadyTimeout }) + async reportOperation(payload: ReportOperationPayload): Promise { + if ('duration' in payload) { + this.emit('launch-performance', { + id: payload.operationId, + name: payload.name, + duration: payload.duration, + success: payload.success, + }) + } else { + this.emit('launch-performance-pre', { + id: payload.operationId, + name: payload.name, + }) + } } } diff --git a/xmcl-runtime/launch/pluginLaunchPrecheck.ts b/xmcl-runtime/launch/pluginLaunchPrecheck.ts new file mode 100644 index 000000000..8e00c4d4a --- /dev/null +++ b/xmcl-runtime/launch/pluginLaunchPrecheck.ts @@ -0,0 +1,69 @@ +import { LaunchPrecheck, MinecraftFolder, diagnoseJar, diagnoseLibraries } from '@xmcl/core' +import { LaunchException } from '@xmcl/runtime-api' +import { LauncherAppPlugin, kGameDataPath } from '~/app' +import { InstallService } from '~/install' +import { JavaService, JavaValidation } from '~/java' +import { LaunchService } from '~/launch' + +export const pluginLaunchPrecheck: LauncherAppPlugin = async (app) => { + const launchService = await app.registry.get(LaunchService) + const installService = await app.registry.get(InstallService) + const getPath = await app.registry.get(kGameDataPath) + const javaService = await app.registry.get(JavaService) + + launchService.registerMiddleware({ + name: 'java-validation', + async onBeforeLaunch(input, output) { + const javaPath = input.java + try { + const result = await javaService.validateJavaPath(javaPath) + if (result === JavaValidation.NotExisted) { + throw new LaunchException({ type: 'launchInvalidJavaPath', javaPath }) + } + if (result === JavaValidation.NoPermission) { + throw new LaunchException({ type: 'launchJavaNoPermission', javaPath }) + } + } catch (e) { + throw new LaunchException({ type: 'launchNoProperJava', javaPath }, 'Cannot launch without a valid java', { cause: e }) + } + }, + }) + launchService.registerMiddleware({ + name: 'check-assets', + async onBeforeLaunch(input, output) { + const resolvedVersion = output.version + if (!input?.skipAssetsCheck) { + const resourceFolder = new MinecraftFolder(getPath()) + await Promise.all([ + diagnoseJar(resolvedVersion, resourceFolder).then((issue) => { + if (issue?.type === 'missing') { + return installService.installMinecraftJar(resolvedVersion) + } + }), + diagnoseLibraries(resolvedVersion, resourceFolder).then(async (libs) => { + const missing = libs.filter((l) => l.type === 'missing') + if (missing.length > 0) { + await installService.installLibraries(libs.map(l => l.library)) + } + }), + ]) + } + }, + }) + launchService.registerMiddleware({ + name: 'check-natives', + async onBeforeLaunch(input, output) { + const resolvedVersion = output.version + const resourceFolder = new MinecraftFolder(getPath()) + await LaunchPrecheck.checkNatives(resourceFolder, resolvedVersion, output) + }, + }) + launchService.registerMiddleware({ + name: 'link-assets', + async onBeforeLaunch(input, output) { + const resolvedVersion = output.version + const resourceFolder = new MinecraftFolder(getPath()) + await LaunchPrecheck.linkAssets(resourceFolder, resolvedVersion, output) + }, + }) +} diff --git a/xmcl-runtime/resourcePack/pluginResourcePackLink.ts b/xmcl-runtime/resourcePack/pluginResourcePackLink.ts index 6083e2bef..0f00c73fc 100644 --- a/xmcl-runtime/resourcePack/pluginResourcePackLink.ts +++ b/xmcl-runtime/resourcePack/pluginResourcePackLink.ts @@ -15,6 +15,7 @@ export const pluginResourcePackLink: LauncherAppPlugin = async (app) => { const getPath = await app.registry.get(kGameDataPath) launchService.registerMiddleware({ + name: 'resources-link', async onBeforeLaunch(input, output) { const path = output.gamePath const linked = await resourcePackService.link(path) diff --git a/xmcl-runtime/telemetry/pluginTelemetry.ts b/xmcl-runtime/telemetry/pluginTelemetry.ts index ff086b3da..8499be274 100644 --- a/xmcl-runtime/telemetry/pluginTelemetry.ts +++ b/xmcl-runtime/telemetry/pluginTelemetry.ts @@ -171,18 +171,32 @@ export const pluginTelemetry: LauncherAppPlugin = async (app) => { }, }) } - }).on('minecraft-launch-status-pre', ({ alreadyTimeout, record }) => { + }).on('launch-performance', ({ name, id, duration }) => { + if (settings.disableTelemetry) return + client.trackEvent({ + name, + measurements: { + duration, + }, + tagOverrides: { + [contract.operationId]: id, + [contract.operationName]: name, + }, + }) + }).on('launch-performance-pre', ({ name, id }) => { + if (settings.disableTelemetry) return client.trackEvent({ - name: 'minecraft-launch-status-pre', - properties: { - alreadyTimeout, - record, + name: name + '-pre', + tagOverrides: { + [contract.operationId]: id, + [contract.operationName]: name, }, }) }) if (!flights.disableMinecraftRunLog) { service.registerMiddleware({ + name: 'minecraft-run-record', async onBeforeLaunch(_, { gamePath }, ctx) { const state = stateManager.get(getInstanceModStateKey(gamePath)) const mods = state?.mods.map(m => {