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
71 changes: 71 additions & 0 deletions apps/server/src/usage/cursorUsageReader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,75 @@ describe("readCursorAccountUsage", () => {
error: "Allow Keychain access on the Mac running T3 Code, then refresh.",
});
});

it("reads the pages behind the first together and keeps them in page order", async () => {
const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
let inFlight = 0;
let mostInFlight = 0;
const result = await readCursorAccountUsage(
{ kind: "keychain" },
0,
1781000000000,
async (_url, init) => {
const page: number = JSON.parse(String(init.body)).page;
inFlight++;
mostInFlight = Math.max(mostInFlight, inFlight);
// Later pages answer first, so record order cannot come from arrival order.
for (let turn = page; turn < 9; turn++) await Promise.resolve();
inFlight--;
return Response.json({
totalUsageEventsCount: 8001,
usageEventsDisplay: Array.from({ length: page === 9 ? 1 : 1000 }, (_, index) => ({
timestamp: String(1780000000000 + (page - 1) * 1000 + index),
model: "gpt-5",
tokenUsage: { inputTokens: 1 },
})),
});
},
async () => accessToken,
);
assert.isNull(result.error);
assert.strictEqual(mostInFlight, 6);
assert.deepStrictEqual(
result.records.map((record) => record.timestampMs),
Array.from({ length: 8001 }, (_, index) => 1780000000000 + index),
);
});

it("ends like a page-by-page read when a page fails, leaving no request open", async () => {
const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
const fullPage = {
totalUsageEventsCount: 8001,
usageEventsDisplay: Array.from({ length: 1000 }, () => ({ tokenUsage: null })),
};
// Page 3 fails while the pages after it are still waiting on Cursor.
for (const [secondPage, error] of [
[() => Response.json(fullPage), "Cursor account usage could not be read."],
[() => new Response(null, { status: 401 }), "Sign in to Cursor again to read account usage."],
] as const) {
let open = 0;
const result = await readCursorAccountUsage(
{ kind: "keychain" },
0,
1781000000000,
(_url, init) => {
const page: number = JSON.parse(String(init.body)).page;
if (page === 1) return Promise.resolve(Response.json(fullPage));
if (page === 2) return Promise.resolve(secondPage());
if (page === 3) return Promise.reject(new Error("connection reset"));
open++;
return new Promise((_resolve, reject) => {
init.signal?.addEventListener("abort", () => {
open--;
reject(init.signal?.reason);
});
});
},
async () => accessToken,
);
assert.strictEqual(result.error, error);
assert.deepStrictEqual(result.records, []);
assert.strictEqual(open, 0);
}
});
});
23 changes: 21 additions & 2 deletions apps/server/src/usage/cursorUsageReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export async function readCursorAccountUsage(
};
}
let accountKey: string | null = null;
const cancel = new AbortController();
try {
const payload = accessToken.split(".")[1];
const subject = object(
Expand All @@ -117,14 +118,14 @@ export async function readCursorAccountUsage(
accountKey = accountHash(subject);
if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate)
throw new Error("Invalid date window");
const deadline = AbortSignal.timeout(60_000);
const deadline = AbortSignal.any([cancel.signal, AbortSignal.timeout(60_000)]);
const records: UsageRecord[] = [];
const occurrences = new Map<string, number>();
const pages: unknown[][] = [];
let completed = false;
const pageSize = 1000;
let total: number | undefined;
for (let page = 1; ; page++) {
const readPage = async (page: number) => {
// A count can include overlapping page boundaries. Allow room to
// reconcile them without imposing a fixed account-size limit.
if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) {
Expand Down Expand Up @@ -181,6 +182,21 @@ export async function readCursorAccountUsage(
throw new Error("Inconsistent account usage page");
}
if (typeof count === "number") total = count;
return events;
};
// Cursor caps a page at 1,000 events and takes about a second to answer one, so the pages
// the first one's count implies are requested up to six ahead and consumed in page order.
const ahead: ReturnType<typeof readPage>[] = [];
for (let page = 1; ; page++) {
while (ahead.length < Math.min(6, Math.ceil((total ?? 0) / pageSize) - page + 1)) {
const pending = readPage(page + ahead.length);
// Handled when its turn comes, or cancelled if the read ends first.
pending.catch(() => undefined);
ahead.push(pending);
}
const events = await (ahead.shift() ?? readPage(page));
// A rejected login comes back as the finished result instead of a page.
if (!Array.isArray(events)) return events;
pages.push(events);
if (events.length < pageSize) {
completed = true;
Expand Down Expand Up @@ -268,5 +284,8 @@ export async function readCursorAccountUsage(
missing: false,
error: "Cursor account usage could not be read.",
};
} finally {
// An early exit leaves the pages requested ahead of it in flight.
cancel.abort();
}
}
Loading