Skip to content

Commit a690a67

Browse files
committed
feat: collection error Slack alerts, resilient usage-events parsing, demo image
- Normalize filtered-usage-events payloads (snake_case, missing fields) - Notify Slack when cron collect fails or returns errors (env-gated) - Add Dockerfile.demo + fly.demo.toml for optional mock-db demo deploy - Allow data/mock.db in Docker context for demo builds Made-with: Cursor
1 parent 9f56eda commit a690a67

6 files changed

Lines changed: 186 additions & 5 deletions

File tree

.dockerignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ node_modules
33
.git
44
data/*.db
55
data/*.db-journal
6+
!data/mock.db
67
coverage
78
.env
89
.env.local

Dockerfile.demo

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
FROM node:22-alpine AS base
2+
3+
FROM base AS deps
4+
WORKDIR /app
5+
COPY package.json package-lock.json* ./
6+
RUN npm ci --omit=dev
7+
8+
FROM base AS builder
9+
WORKDIR /app
10+
COPY package.json package-lock.json* ./
11+
RUN npm ci
12+
COPY . .
13+
RUN npm run build
14+
15+
FROM base AS runner
16+
WORKDIR /app
17+
18+
ENV NODE_ENV=production
19+
ENV NEXT_TELEMETRY_DISABLED=1
20+
21+
RUN addgroup --system --gid 1001 nodejs
22+
RUN adduser --system --uid 1001 nextjs
23+
24+
COPY --from=builder /app/public ./public
25+
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
26+
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
27+
28+
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
29+
COPY --chown=nextjs:nodejs data/mock.db /app/data/tracker.db
30+
31+
USER nextjs
32+
33+
EXPOSE 3000
34+
35+
ENV PORT=3000
36+
ENV HOSTNAME="0.0.0.0"
37+
ENV DATABASE_PATH=/app/data/tracker.db
38+
39+
CMD ["node", "server.js"]

fly.demo.toml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
app = "cursor-usage-tracker-demo"
2+
primary_region = "ams"
3+
4+
[build]
5+
dockerfile = "Dockerfile.demo"
6+
7+
[env]
8+
PORT = "3000"
9+
NODE_ENV = "production"
10+
DATABASE_PATH = "/app/data/tracker.db"
11+
12+
[http_service]
13+
internal_port = 3000
14+
force_https = true
15+
auto_stop_machines = true
16+
auto_start_machines = true
17+
min_machines_running = 0
18+
19+
[[vm]]
20+
memory = "256mb"
21+
cpu_kind = "shared"
22+
cpus = 1

src/app/api/cron/route.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { collectAll } from "@/lib/collector";
33
import { runDetection } from "@/lib/anomaly/detector";
44
import { processNewAnomalies } from "@/lib/incidents";
55
import { sendAlerts } from "@/lib/alerts";
6-
import { sendPlanExhaustionAlert, sendCycleSummary } from "@/lib/alerts/slack";
6+
import {
7+
sendPlanExhaustionAlert,
8+
sendCycleSummary,
9+
sendCollectionErrorAlert,
10+
} from "@/lib/alerts/slack";
711
import {
812
getMetadata,
913
setMetadata,
@@ -30,10 +34,21 @@ export async function POST(request: Request) {
3034
try {
3135
const collectionResult = await collectAll();
3236
results.collection = collectionResult;
37+
38+
if (collectionResult.errors.length > 0) {
39+
const sent = await sendCollectionErrorAlert(collectionResult.errors, {
40+
dashboardUrl: process.env.DASHBOARD_URL,
41+
});
42+
results.collectionErrorAlert = sent ? "sent" : "skipped";
43+
}
3344
} catch (error) {
34-
results.collection = {
35-
error: error instanceof Error ? error.message : String(error),
36-
};
45+
const msg = error instanceof Error ? error.message : String(error);
46+
results.collection = { error: msg };
47+
48+
const sent = await sendCollectionErrorAlert([msg], {
49+
dashboardUrl: process.env.DASHBOARD_URL,
50+
});
51+
results.collectionErrorAlert = sent ? "sent" : "skipped";
3752
}
3853

3954
try {

src/lib/alerts/slack.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,57 @@ export async function sendCycleSummary(
347347
);
348348
}
349349

350+
export async function sendCollectionErrorAlert(
351+
errors: string[],
352+
options: { dashboardUrl?: string } = {},
353+
): Promise<boolean> {
354+
const token = process.env.SLACK_BOT_TOKEN;
355+
const channel = process.env.SLACK_CHANNEL_ID;
356+
if (!token || !channel) return false;
357+
358+
const errorList = errors
359+
.slice(0, 10)
360+
.map((e) => `• ${e}`)
361+
.join("\n");
362+
363+
const blocks: SlackBlock[] = [
364+
{
365+
type: "header",
366+
text: {
367+
type: "plain_text",
368+
text: ":x: Cursor — Data Collection Errors",
369+
emoji: true,
370+
},
371+
},
372+
{
373+
type: "section",
374+
text: {
375+
type: "mrkdwn",
376+
text: `*${errors.length} error${errors.length > 1 ? "s" : ""}* during scheduled collection:\n${errorList}`,
377+
},
378+
},
379+
];
380+
381+
if (options.dashboardUrl) {
382+
blocks.push({
383+
type: "section",
384+
text: { type: "mrkdwn", text: `<${options.dashboardUrl}|View dashboard>` },
385+
});
386+
}
387+
388+
blocks.push({
389+
type: "context",
390+
elements: [{ type: "mrkdwn", text: `${new Date().toISOString()} · cursor-usage-tracker` }],
391+
});
392+
393+
return postToSlack(
394+
token,
395+
channel,
396+
`Cursor — ${errors.length} collection error${errors.length > 1 ? "s" : ""}: ${errors[0]}`,
397+
blocks,
398+
);
399+
}
400+
350401
export async function sendSlackBatch(
351402
pairs: Array<{ anomaly: Anomaly; incident: Incident }>,
352403
options: { dashboardUrl?: string } = {},

src/lib/cursor-client.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
MemberSpend,
66
SpendResponse,
77
GroupsResponse,
8+
FilteredUsageEvent,
89
FilteredUsageEventsResponse,
910
AICodeCommitsResponse,
1011
AnalyticsDAUResponse,
@@ -25,6 +26,50 @@ interface CursorClientOptions {
2526
baseUrl?: string;
2627
}
2728

29+
function num(v: unknown, fallback = 0): number {
30+
return typeof v === "number" && !Number.isNaN(v) ? v : fallback;
31+
}
32+
33+
function str(raw: Record<string, unknown>, ...keys: string[]): string {
34+
for (const k of keys) {
35+
const v = raw[k];
36+
if (typeof v === "string" && v !== "") return v;
37+
if (typeof v === "number" && !Number.isNaN(v)) return String(v);
38+
}
39+
return "";
40+
}
41+
42+
function bool(v: unknown): boolean {
43+
return v === true || v === 1 || v === "1";
44+
}
45+
46+
function normalizeFilteredUsageEvent(raw: Record<string, unknown>): FilteredUsageEvent {
47+
const tuRaw = raw.tokenUsage ?? raw.token_usage;
48+
let tokenUsage: FilteredUsageEvent["tokenUsage"];
49+
if (tuRaw && typeof tuRaw === "object") {
50+
const t = tuRaw as Record<string, unknown>;
51+
tokenUsage = {
52+
inputTokens: num(t.inputTokens ?? t.input_tokens),
53+
outputTokens: num(t.outputTokens ?? t.output_tokens),
54+
cacheWriteTokens: num(t.cacheWriteTokens ?? t.cache_write_tokens),
55+
cacheReadTokens: num(t.cacheReadTokens ?? t.cache_read_tokens),
56+
totalCents: num(t.totalCents ?? t.total_cents),
57+
};
58+
}
59+
return {
60+
timestamp: str(raw, "timestamp"),
61+
model: str(raw, "model"),
62+
kind: str(raw, "kind", "kindLabel", "kind_label"),
63+
maxMode: bool(raw.maxMode ?? raw.max_mode),
64+
requestsCosts: num(raw.requestsCosts ?? raw.requests_costs),
65+
isTokenBasedCall: bool(raw.isTokenBasedCall ?? raw.is_token_based_call),
66+
tokenUsage,
67+
userEmail: str(raw, "userEmail", "user_email", "email"),
68+
isChargeable: Boolean(raw.isChargeable ?? raw.is_chargeable),
69+
isHeadless: bool(raw.isHeadless ?? raw.is_headless),
70+
};
71+
}
72+
2873
export class CursorClient {
2974
private apiKey: string;
3075
private baseUrl: string;
@@ -224,7 +269,7 @@ export class CursorClient {
224269
page?: number;
225270
pageSize?: number;
226271
}): Promise<FilteredUsageEventsResponse> {
227-
return this.request<FilteredUsageEventsResponse>("/teams/filtered-usage-events", {
272+
const data = await this.request<FilteredUsageEventsResponse>("/teams/filtered-usage-events", {
228273
method: "POST",
229274
body: {
230275
email: options.email,
@@ -234,6 +279,14 @@ export class CursorClient {
234279
pageSize: options.pageSize ?? 500,
235280
},
236281
});
282+
const usageEvents = (data.usageEvents ?? []).map((row) =>
283+
normalizeFilteredUsageEvent(
284+
row && typeof row === "object" && !Array.isArray(row)
285+
? (row as unknown as Record<string, unknown>)
286+
: {},
287+
),
288+
);
289+
return { ...data, usageEvents };
237290
}
238291

239292
async getAICodeCommits(

0 commit comments

Comments
 (0)