Skip to content

Commit 2dc4bbf

Browse files
feat(cli): forward a reported catalog gap to the feedback channel (#3204)
A search miss already reached PostHog, where nobody was watching it. It now also goes to the same channel a CLI rating goes to, by the same best-effort POST, so a move the catalog is missing is read rather than queried. The forward is bounded and swallowed, and the ack prints before it, so a gap report can never fail or delay the command that sent it.
1 parent 353b9eb commit 2dc4bbf

3 files changed

Lines changed: 103 additions & 7 deletions

File tree

packages/cli/src/commands/feedback.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { shouldTrack, flush } from "../telemetry/client.js";
1010
import { getDoctorSummary } from "../telemetry/feedback.js";
1111
import { readConfig, type RecentRenderRecord } from "../telemetry/config.js";
1212
import { publishProjectArchive } from "../utils/publishProject.js";
13-
import { submitFeedback } from "../utils/submitFeedback.js";
13+
import { submitCatalogSearchMiss, submitFeedback } from "../utils/submitFeedback.js";
1414
import { buildIssueUrl, HYPERFRAMES_REPO_URL } from "../utils/feedbackIssue.js";
1515
import { VERSION } from "../version.js";
1616
import { c } from "../ui/colors.js";
@@ -205,13 +205,14 @@ export default defineCommand({
205205
console.log(c.dim("Telemetry is disabled. Nothing sent."));
206206
return;
207207
}
208-
trackCatalogSearchMiss({
209-
query: searchMiss,
210-
wanted: normalizeComment(args.wanted),
211-
tier: normalizeComment(args.tier),
212-
});
208+
const wanted = normalizeComment(args.wanted);
209+
const tier = normalizeComment(args.tier);
210+
trackCatalogSearchMiss({ query: searchMiss, wanted, tier });
213211
await flush();
212+
// Ack before the forward, which is best-effort and bounded, so the
213+
// reporter is never left waiting on it.
214214
console.log(c.dim("Logged the gap. Thanks — that is how the catalog grows."));
215+
await submitCatalogSearchMiss({ query: searchMiss, wanted, tier, cliVersion: VERSION });
215216
return;
216217
}
217218

packages/cli/src/utils/submitFeedback.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ vi.mock("./publishProject.js", () => ({
66
getPublishApiBaseUrl: getPublishApiBaseUrlMock,
77
}));
88

9-
import { submitFeedback } from "./submitFeedback.js";
9+
import { submitCatalogSearchMiss, submitFeedback } from "./submitFeedback.js";
1010

1111
describe("submitFeedback", () => {
1212
beforeEach(() => {
@@ -98,3 +98,59 @@ describe("submitFeedback", () => {
9898
await expect(submitFeedback({ rating: 3, cliVersion: "1.2.3" })).resolves.toBeUndefined();
9999
});
100100
});
101+
102+
describe("submitCatalogSearchMiss", () => {
103+
beforeEach(() => {
104+
getPublishApiBaseUrlMock.mockReturnValue("https://api.example.com");
105+
});
106+
107+
afterEach(() => {
108+
vi.clearAllMocks();
109+
vi.unstubAllGlobals();
110+
});
111+
112+
it("posts the gap to the catalog endpoint", async () => {
113+
const fetchMock = vi.fn<typeof fetch>(async () => new Response(null, { status: 202 }));
114+
vi.stubGlobal("fetch", fetchMock);
115+
116+
await submitCatalogSearchMiss({
117+
query: "typewriter that deletes",
118+
wanted: "text that types then backspaces",
119+
tier: "on-device",
120+
cliVersion: "0.7.106",
121+
});
122+
123+
expect(fetchMock).toHaveBeenCalledOnce();
124+
const [url, init] = fetchMock.mock.calls[0]!;
125+
expect(url).toBe("https://api.example.com/v1/hyperframes/catalog_search_miss");
126+
expect(JSON.parse(String(init?.body))).toEqual({
127+
query: "typewriter that deletes",
128+
wanted: "text that types then backspaces",
129+
tier: "on-device",
130+
cli_version: "0.7.106",
131+
});
132+
});
133+
134+
it("truncates a field the backend would reject outright", async () => {
135+
const fetchMock = vi.fn<typeof fetch>(async () => new Response(null, { status: 202 }));
136+
vi.stubGlobal("fetch", fetchMock);
137+
138+
await submitCatalogSearchMiss({ query: "q".repeat(900), cliVersion: "0.7.106" });
139+
140+
const [, init] = fetchMock.mock.calls[0]!;
141+
expect(JSON.parse(String(init?.body)).query).toHaveLength(500);
142+
});
143+
144+
it("never throws when the forward fails", async () => {
145+
vi.stubGlobal(
146+
"fetch",
147+
vi.fn<typeof fetch>(async () => {
148+
throw new Error("offline");
149+
}),
150+
);
151+
152+
await expect(
153+
submitCatalogSearchMiss({ query: "a query", cliVersion: "0.7.106" }),
154+
).resolves.toBeUndefined();
155+
});
156+
});

packages/cli/src/utils/submitFeedback.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,42 @@ export async function submitFeedback(input: {
3939
// Best-effort only.
4040
}
4141
}
42+
43+
const MAX_QUERY = 500;
44+
const MAX_WANTED = 500;
45+
const MAX_TIER = 50;
46+
47+
/**
48+
* Forward a reported catalog gap to the same place a rating goes.
49+
*
50+
* A miss is the one search report that leaves the machine, and it is worth
51+
* more to a person reading a channel than to a chart: it names a move the
52+
* catalog does not have yet. Best-effort and bounded, exactly like
53+
* `submitFeedback` — a gap report must never fail the command that sent it.
54+
*/
55+
export async function submitCatalogSearchMiss(input: {
56+
query: string;
57+
wanted?: string;
58+
tier?: string;
59+
cliVersion: string;
60+
}): Promise<void> {
61+
try {
62+
const apiBaseUrl = getPublishApiBaseUrl();
63+
await fetch(`${apiBaseUrl}/v1/hyperframes/catalog_search_miss`, {
64+
method: "POST",
65+
body: JSON.stringify({
66+
query: cap(input.query, MAX_QUERY),
67+
wanted: cap(input.wanted, MAX_WANTED),
68+
tier: cap(input.tier, MAX_TIER),
69+
cli_version: cap(input.cliVersion, MAX_CLI_VERSION),
70+
}),
71+
headers: {
72+
"content-type": "application/json",
73+
heygen_route: "canary",
74+
},
75+
signal: AbortSignal.timeout(5000),
76+
});
77+
} catch {
78+
// Best-effort only.
79+
}
80+
}

0 commit comments

Comments
 (0)