Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions etc/vortex.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2192,6 +2192,10 @@ interface IEnableOptions {
// (undocumented)
installed?: boolean;
// (undocumented)
reason?: ModChangeReason;
// (undocumented)
skipStateChangeEvent?: boolean;
// (undocumented)
willBeReplaced?: boolean;
}

Expand Down Expand Up @@ -3485,6 +3489,8 @@ interface IProfile {

// @public (undocumented)
interface IProfileMod {
// (undocumented)
disabledTime?: number;
// (undocumented)
enabled: boolean;
// (undocumented)
Expand Down Expand Up @@ -3601,6 +3607,8 @@ interface IRemoveModOptions {
// (undocumented)
progressCB?: (numRemoved: number, numTotal: number, name: string) => void;
// (undocumented)
reason?: ModChangeReason;
// (undocumented)
silent?: boolean;
// (undocumented)
willBeReplaced?: boolean;
Expand Down Expand Up @@ -4653,6 +4661,9 @@ export class Modal extends React_2.PureComponent<typeof Modal_2.prototype.props,
static Title: typeof ModalTitle;
}

// @public
type ModChangeReason = "user_manual" | "variant_replace" | "version_update" | "profile_replace" | "collection_update" | "collection_uninstall" | "stop_managing_game" | "health_check";

// Warning: (ae-forgotten-export) The symbol "INameOptions" needs to be exported by the entry point api.d.ts
//
// @public
Expand Down Expand Up @@ -6468,6 +6479,7 @@ declare namespace util {
Normalize,
ISteamEntry,
CollectionInstallOutcomeProps,
ModChangeReason,
Archive,
ArgumentInvalid,
batchDispatch,
Expand Down
7 changes: 6 additions & 1 deletion src/renderer/src/IPCDownloadAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,12 @@ export class IPCDownloadAdapter {
}

if (eventType === "completed") {
const duration_ms = Date.now() - (download?.fileTime ?? Date.now());
// Elapsed since the download's durable startTime (stamped when it first transitions to
// "started", preserved across pause/resume and restarts). 0 when there is no start stamp
// (e.g. locally-added archives).
const startTime = download?.startTime;
const duration_ms =
typeof startTime === "number" && startTime > 0 ? Date.now() - startTime : 0;
const file_size = download?.size ?? 0;
if (isCollection && nexusIds.collectionId && nexusIds.revisionId) {
this.#api.events.emit(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,11 +421,30 @@ export class ModsDownloadFailedEvent implements MixpanelEvent {
}
}

/**
* What kind of install produced a mod, on the mods_installation_* events. Lets the data team
* separate fresh installs from version bumps, reinstalls, and the two name-conflict resolutions.
* - fresh: no prior version of this mod was installed.
* - version_update: a different file (version) of an already-installed mod.
* - reinstall: the same file (version) reinstalled over itself.
* - variant: installed as a second, coexisting copy under a different variant name.
* - profile_replace: replaced the existing mod across all local profiles.
*/
export type ModInstallKind =
| "fresh"
| "version_update"
| "reinstall"
| "variant"
| "profile_replace";

/** Fields shared by every per-mod install event: identity + how the install came about. */
type ModInstallProps = ModAnalyticsIdentity & { install_kind: ModInstallKind };

/** Event sent when mod installation is started. Not sent for the collection bundle/manifest mod. */
export class ModsInstallationStartedEvent implements MixpanelEvent {
readonly eventName = "mods_installation_started";
readonly properties: Record<string, any>;
constructor(props: ModAnalyticsIdentity) {
constructor(props: ModInstallProps) {
this.properties = { ...props };
}
}
Expand All @@ -434,7 +453,7 @@ export class ModsInstallationStartedEvent implements MixpanelEvent {
export class ModsInstallationCompletedEvent implements MixpanelEvent {
readonly eventName = "mods_installation_completed";
readonly properties: Record<string, any>;
constructor(props: ModAnalyticsIdentity & { duration_ms: number }) {
constructor(props: ModInstallProps & { duration_ms: number }) {
this.properties = { ...props };
}
}
Expand All @@ -443,7 +462,7 @@ export class ModsInstallationCompletedEvent implements MixpanelEvent {
export class ModsInstallationCancelledEvent implements MixpanelEvent {
readonly eventName = "mods_installation_cancelled";
readonly properties: Record<string, any>;
constructor(props: ModAnalyticsIdentity) {
constructor(props: ModInstallProps) {
this.properties = { ...props };
}
}
Expand All @@ -452,7 +471,62 @@ export class ModsInstallationCancelledEvent implements MixpanelEvent {
export class ModsInstallationFailedEvent implements MixpanelEvent {
readonly eventName = "mods_installation_failed";
readonly properties: Record<string, any>;
constructor(props: ModAnalyticsIdentity & { error_code: string; error_message: string }) {
constructor(props: ModInstallProps & { error_code: string; error_message: string }) {
this.properties = { ...props };
}
}

/**
* Why a mod was enabled/disabled/removed, on the ModsStateChanged / ModsRemoved events.
* A small, closed vocabulary so the data team can slice programmatic churn (collection
* updates/uninstalls, variant/version/profile replacement, health-check remediation) from
* user-driven changes. Install-completion enables are not represented here: they are covered
* by the mods_installation_* events, so no enable is emitted for them.
*/
export type ModChangeReason =
| "user_manual"
| "variant_replace"
| "version_update"
| "profile_replace"
| "collection_update"
| "collection_uninstall"
| "stop_managing_game"
| "health_check";

/**
* Event sent when a mod is enabled or disabled in a profile. Shares the per-mod identity
* so it joins to the mod's download/install events; `reason` records what drove the change.
* Not sent for the collection mod itself (collections have their own lifecycle events) nor
* for mods without a Nexus file id (bundled/local), matching the other per-mod events.
*
* `duration_ms` is how long the mod spent in the prior state before this change: time enabled on
* a disable, time disabled on an enable. 0 when that span is unknown (never in that state).
*/
export class ModsStateChangedEvent implements MixpanelEvent {
readonly eventName = "mods_state_changed";
readonly properties: Record<string, any>;
constructor(
props: ModAnalyticsIdentity & {
change: "enabled" | "disabled";
reason: ModChangeReason;
duration_ms: number;
},
) {
this.properties = { ...props };
}
}

/**
* Event sent when a mod is removed. `will_be_replaced` marks removals that are part of a
* reinstall/variant/version replacement (a new mod takes its place) rather than a genuine
* uninstall. Same gating and identity as ModsStateChangedEvent.
*/
export class ModsRemovedEvent implements MixpanelEvent {
readonly eventName = "mods_removed";
readonly properties: Record<string, any>;
constructor(
props: ModAnalyticsIdentity & { reason: ModChangeReason; will_be_replaced: boolean },
) {
this.properties = { ...props };
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import type { nexusIdsFromDownloadId } from "../../nexus_integration/selectors";
import { makeModAndFileUIDs } from "../../nexus_integration/util/UIDs";
import type { ModAnalyticsIdentity } from "./MixpanelEvents";

/**
* The resolved nexus identity of a download record. A collection is itself a mod, so
* this same shape describes both a mod and a collection - we take the mod fields here.
* The nexus fields needed to build an analytics identity. A collection is itself a mod, so
* this same shape describes both; satisfied both by a resolved download record
* (nexusIdsFromDownloadId) and by an installed mod's own attributes.
*/
type ResolvedNexusIds = NonNullable<ReturnType<typeof nexusIdsFromDownloadId>>;
export interface ModNexusIds {
numericGameId: number;
modId: string;
fileId: string;
}

/**
* Builds the shared per-mod analytics identity (mod/file ids + UIDs + collection_id)
* from resolved nexus ids, so the download and install emit sites produce an identical
* identity. `collectionId` is the parent collection when installed as part of one, else null.
*/
export function makeModAnalyticsIdentity(
nexusIds: ResolvedNexusIds,
nexusIds: ModNexusIds,
collectionId: string | null,
): ModAnalyticsIdentity {
const { modUID, fileUID } = makeModAndFileUIDs(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* Tests for the mod enable/disable/remove analytics: the emit helpers resolve identity from the
* mod's own Nexus attributes, gate out collections and non-Nexus (bundled/local) mods, and carry
* the change/reason/duration.
*/
import { expect } from "vitest";

import { makeMod, makeProfile, makeProfileMod } from "../../../test-utils/builders";
import type { IModChangeHarness } from "../../../test-utils/harnessTypes";
import { test } from "../../../test-utils/modChangeTest";
import type { IMod } from "../../mod_management/types/IMod";
import type { IProfileMod } from "../../profile_management/types/IProfile";
import { emitModRemoved, emitModStateChanged } from "./modChangeAnalytics";

const GAME = "skyrimse";
const MOD = "mod-1";
const PROFILE = "prof-1";
const NEXUS_ATTRS = { source: "nexus", modId: 100, fileId: 200, downloadGame: GAME };

const nexusMod = (attributeOverrides: Record<string, unknown> = {}): IMod =>
makeMod({ id: MOD, attributes: { ...NEXUS_ATTRS, ...attributeOverrides } });

const seed = (mod: IMod) => ({ mods: { [GAME]: { [MOD]: mod } } });

// Seed the mod's state in the game's active profile, so a change can measure how long it had
// spent in the prior state (enabledTime for a disable, disabledTime for an enable).
const seedModState = (h: IModChangeHarness, modState: Partial<IProfileMod>) =>
h.setState((draft) => {
draft.settings.profiles.lastActiveProfile[GAME] = PROFILE;
draft.persistent.profiles[PROFILE] = makeProfile({
id: PROFILE,
gameId: GAME,
modState: { [MOD]: makeProfileMod(modState) },
});
});

test("emitModStateChanged emits mods_state_changed with the change, reason and Nexus identity", ({
makeModChange,
}) => {
const h = makeModChange(seed(nexusMod()));
emitModStateChanged(h.api, GAME, MOD, "enabled", "user_manual");
expect(h.mixpanelEvents).toHaveLength(1);
expect(h.mixpanelEvents[0].eventName).toBe("mods_state_changed");
expect(h.mixpanelEvents[0].properties).toMatchObject({
change: "enabled",
reason: "user_manual",
mod_id: "100",
file_id: "200",
collection_id: null,
});
});

test("carries a disabled change with the supplied reason and collection id", ({
makeModChange,
}) => {
const h = makeModChange(seed(nexusMod()));
emitModStateChanged(h.api, GAME, MOD, "disabled", "variant_replace", "col-1");
expect(h.mixpanelEvents[0].properties).toMatchObject({
change: "disabled",
reason: "variant_replace",
collection_id: "col-1",
});
});

test("a disable reports how long the mod had been enabled as duration_ms", ({ makeModChange }) => {
const h = makeModChange(seed(nexusMod()));
seedModState(h, { enabled: true, enabledTime: Date.now() - 60_000 });
emitModStateChanged(h.api, GAME, MOD, "disabled", "user_manual");
expect(h.mixpanelEvents[0].properties.duration_ms).toBeGreaterThanOrEqual(60_000);
});

test("an enable reports how long the mod had been disabled as duration_ms", ({ makeModChange }) => {
const h = makeModChange(seed(nexusMod()));
seedModState(h, { enabled: false, disabledTime: Date.now() - 60_000 });
emitModStateChanged(h.api, GAME, MOD, "enabled", "user_manual");
expect(h.mixpanelEvents[0].properties.duration_ms).toBeGreaterThanOrEqual(60_000);
});

test("reports duration_ms 0 when the prior-state timestamp is unknown", ({ makeModChange }) => {
const h = makeModChange(seed(nexusMod()));
seedModState(h, { enabled: false }); // never disabled with a stamp -> unknown
emitModStateChanged(h.api, GAME, MOD, "enabled", "user_manual");
expect(h.mixpanelEvents[0].properties.duration_ms).toBe(0);
});

test("does not emit for a non-Nexus (bundled/local) mod", ({ makeModChange }) => {
const h = makeModChange(seed(nexusMod({ source: "user-generated" })));
emitModStateChanged(h.api, GAME, MOD, "enabled", "user_manual");
expect(h.mixpanelEvents).toHaveLength(0);
});

test("does not emit when the mod has no Nexus file id", ({ makeModChange }) => {
const h = makeModChange(seed(nexusMod({ fileId: undefined })));
emitModStateChanged(h.api, GAME, MOD, "enabled", "user_manual");
expect(h.mixpanelEvents).toHaveLength(0);
});

test("does not emit for an unknown mod id", ({ makeModChange }) => {
const h = makeModChange(seed(nexusMod()));
emitModStateChanged(h.api, GAME, "does-not-exist", "enabled", "user_manual");
expect(h.mixpanelEvents).toHaveLength(0);
});

test("emitModRemoved emits mods_removed with the reason and will_be_replaced", ({
makeModChange,
}) => {
const h = makeModChange();
emitModRemoved(h.api, nexusMod(), "collection_uninstall", true);
expect(h.mixpanelEvents).toHaveLength(1);
expect(h.mixpanelEvents[0].eventName).toBe("mods_removed");
expect(h.mixpanelEvents[0].properties).toMatchObject({
reason: "collection_uninstall",
will_be_replaced: true,
mod_id: "100",
});
});

test("emitModRemoved does not emit for a non-Nexus mod", ({ makeModChange }) => {
const h = makeModChange();
emitModRemoved(
h.api,
makeMod({ id: MOD, attributes: { source: "user-generated" } }),
"user_manual",
false,
);
expect(h.mixpanelEvents).toHaveLength(0);
});
Loading