1import { type OrchestrationV2Command, WS_METHODS } from "@t3tools/contracts";
2
3export interface FeatureUsage {
4 readonly feature: string;
5 readonly variant?: string;
6}
7
8type Payload = Readonly<Record<string, unknown>>;
9
10const literal = (payload: Payload, key: string) =>
11 typeof payload[key] === "string" ? (payload[key] as string) : undefined;
12
13const use = (feature: string, variant?: string): FeatureUsage =>
14 variant === undefined ? { feature } : { feature, variant };
15
16/**
17 * Exclude housekeeping. `client.turn.requested` counts every `message.dispatch`; only sending a
18 * guest's message is also a feature. Record at command dispatch so retries do not count twice.
19 */
20const COMMAND_FEATURES: {
21 readonly [Type in OrchestrationV2Command["type"]]?: (
22 command: Extract<OrchestrationV2Command, { readonly type: Type }>,
23 ) => FeatureUsage | undefined;
24} = {
25 "message.dispatch": (command) =>
26 command.guestMessageId === undefined ? undefined : { feature: "guest_message.send" },
27 "checkpoint.rollback": (command) => ({
28 feature: "checkpoint.rollback",
29 variant: command.restoreFiles === false ? "conversation_only" : "with_files",
30 }),
31 "thread.fork": () => ({ feature: "thread.fork" }),
32 "thread.merge_back": () => ({ feature: "thread.merge_back" }),
33 "provider.switch": () => ({ feature: "provider.switch" }),
34 "thread.archive": () => ({ feature: "thread.archive" }),
35 "thread.snooze": () => ({ feature: "thread.snooze" }),
36 "thread.pin": () => ({ feature: "thread.pin" }),
37 "thread.settle": () => ({ feature: "thread.settle" }),
38 "thread.pull-request.link": () => ({ feature: "thread.pull_request.link" }),
39 "thread.pull-request.watch": (command) =>
40 use("thread.pull_request.watch", command.watching ? "on" : "off"),
41 "thread.runtime-mode.set": (command) => use("thread.runtime_mode.set", command.runtimeMode),
42 "thread.interaction-mode.set": (command) =>
43 use("thread.interaction_mode.set", command.interactionMode),
44 "run.interrupt": () => ({ feature: "run.interrupt" }),
45 "queued-message.promote-to-steer": () => ({ feature: "queue.steer" }),
46 "queued-run.edit": () => ({ feature: "queue.edit" }),
47 "thread.guest-share.close": () => ({ feature: "guest_share.close" }),
48 "thread.guest-message.dismiss": () => ({ feature: "guest_message.dismiss" }),
49};
50
51/**
52 * RPCs that represent a user starting a feature. Variants come only from
53 * closed enums in the contracts, so no path, id, or free text can reach analytics.
54 * Thread launches are left out because `client.thread.started` counts them.
55 */
56const METHOD_FEATURES: Readonly<Record<string, (payload: Payload) => FeatureUsage>> = {
57 [WS_METHODS.terminalOpen]: () => ({ feature: "terminal.open" }),
58 [WS_METHODS.previewOpen]: () => ({ feature: "preview.open" }),
59 [WS_METHODS.deviceOpen]: () => ({ feature: "device.open" }),
60 [WS_METHODS.shellOpenInEditor]: (payload) => use("editor.open", literal(payload, "editor")),
61 [WS_METHODS.vcsCreateWorktree]: () => ({ feature: "worktree.create" }),
62 [WS_METHODS.gitRunStackedAction]: (payload) =>
63 use("git.stacked_action", literal(payload, "action")),
64 [WS_METHODS.pullRequestsRunAction]: (payload) =>
65 use("pull_request.action", literal(payload, "action")),
66 [WS_METHODS.pullRequestsSubmitReview]: () => ({ feature: "pull_request.review" }),
67 [WS_METHODS.pullRequestsComment]: () => ({ feature: "pull_request.comment" }),
68 [WS_METHODS.scheduledTasksUpsert]: () => ({ feature: "scheduled_task.save" }),
69 [WS_METHODS.scheduledTasksRunNow]: () => ({ feature: "scheduled_task.run_now" }),
70 [WS_METHODS.agentSessionsImport]: () => ({ feature: "agent_session.import" }),
71 [WS_METHODS.projectCloneStart]: () => ({ feature: "project.clone" }),
72 [WS_METHODS.sourceControlPublishRepository]: () => ({ feature: "repository.publish" }),
73 [WS_METHODS.sourceControlSignInStart]: () => ({ feature: "source_control.sign_in" }),
74 [WS_METHODS.serverUpsertKeybinding]: () => ({ feature: "keybinding.customize" }),
75 [WS_METHODS.guestShareCreate]: () => ({ feature: "guest_share.create" }),
76};
77
78/** The feature a dispatched command uses, or undefined when it is not a tracked feature. */
79export function commandFeatureUsage(command: OrchestrationV2Command): FeatureUsage | undefined {
80 // TypeScript cannot pair a union's tag with its own entry, so widen once here.
81 const mapping = COMMAND_FEATURES[command.type] as
82 | ((command: OrchestrationV2Command) => FeatureUsage | undefined)
83 | undefined;
84 return mapping?.(command);
85}
86
87/** The feature a WebSocket request uses, or undefined when it is not a tracked feature. */
88export function featureUsage(method: string, payload: unknown): FeatureUsage | undefined {
89 if (typeof payload !== "object" || payload === null) return undefined;
90 return METHOD_FEATURES[method]?.(payload as Payload);
91}