Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5dc6c9b
perf: non-blocking covering index for the hourly rollup scan (kwf spe…
iscekic Sep 23, 2026
f7c785f
feat: split the delivery drain into its own cron and add the hourly-b…
iscekic Sep 23, 2026
c8ee171
perf: web spend view: cut the slot reservation to the measured form a…
iscekic Sep 23, 2026
df2c802
perf: organization-scope recipients: the owner and billing managers o…
iscekic Sep 23, 2026
686e226
perf: sweep: one set-based decision per tick, bounded reads, honest d…
iscekic Sep 23, 2026
58af2f8
perf: mobile spend-alerts screen: name the scope, say what each rule …
iscekic Sep 23, 2026
cdb8750
refactor: web panel: reserve the save-error row so a failed save cann…
iscekic Sep 23, 2026
c9a5107
perf: UX: An organization owner who holds the owner membership role b…
iscekic Sep 23, 2026
24223e1
perf: UX: When the settings query resolves, the new scope line appear…
iscekic Sep 23, 2026
f41e511
style: apply the repo formatter
iscekic Sep 23, 2026
4a4a497
fix(spend-alerts): regenerate the rollup index migration on current main
iscekic Sep 24, 2026
5fe4c66
fix(spend-alerts): surface a sweep remainder that outgrows the rollup…
iscekic Sep 25, 2026
0603601
test(spend-alerts): pin the sweep's remainder-window log fields
iscekic Sep 25, 2026
73477fa
chore(db): drop the branch-local rollup index migration before regene…
iscekic Sep 25, 2026
9e13211
Merge remote-tracking branch 'origin/main' into pr-6670-fix
iscekic Sep 25, 2026
11c20cb
fix(db): regenerate the rollup covering index as 0263 on merged main
iscekic Sep 25, 2026
2e4fd66
Merge remote-tracking branch 'origin/main' into kwf/spend-alerts-opti…
iscekic Sep 25, 2026
598179b
fix(web): resolve spend-alert recipients from current membership
iscekic Sep 25, 2026
8ebae94
test(spend-alerts): delete test organizations in afterEach, not afterAll
iscekic Sep 25, 2026
67c3c2f
Merge remote-tracking branch 'origin/main' into kwf/spend-alerts-opti…
iscekic Sep 25, 2026
ec74c1b
Merge remote-tracking branch 'origin/main' into kwf/spend-alerts-opti…
iscekic Sep 28, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,11 @@ function fieldByLabel(renderer: ReactTestRenderer, label: string): ReactTestInst
return field;
}

/** The scope group renders the owner row first, then the 24 h spend row. */
function kvRowByLabel(renderer: ReactTestRenderer, label: string): ReactTestInstance | undefined {
return byType(renderer, 'KvRow').find(node => node.props.label === label);
}

function buttonByVariant(renderer: ReactTestRenderer, variant?: string): ReactTestInstance {
const button = byType(renderer, 'Button').find(node => node.props.variant === variant);
if (!button) {
Expand Down Expand Up @@ -282,7 +287,7 @@ describe('SpendAlertsScreen happy state', () => {

expect(fieldByLabel(renderer, LIMIT).props.defaultValue).toBe('25');
expect(fieldByLabel(renderer, MULTIPLIER).props.defaultValue).toBe('2');
expect(byType(renderer, 'KvRow')[0]?.props.value).toBe('$1.5');
expect(kvRowByLabel(renderer, i18n.t('spendAlerts.spend24h'))?.props.value).toBe('$1.5');
expect(switchByLabel(renderer, i18n.t('spendAlerts.enable'))?.props.value).toBe(true);
expect(switchByLabel(renderer, i18n.t('spendAlerts.thresholdTitle'))?.props.value).toBe(true);
expect(byType(renderer, 'SegmentedControl')[0]?.props.value).toBe('24');
Expand Down Expand Up @@ -364,7 +369,65 @@ describe('SpendAlertsScreen happy state', () => {
spend: { spend24hMicrodollars: 0, spend7dMicrodollars: 0, baselineHourlyMicrodollars: null },
});

expect(byType(renderer, 'KvRow')[0]?.props.value).toBe(i18n.t('spendAlerts.noSpend'));
expect(kvRowByLabel(renderer, i18n.t('spendAlerts.spend24h'))?.props.value).toBe(
i18n.t('spendAlerts.noSpend')
);
});
});

describe('SpendAlertsScreen scope line', () => {
it('names the personal scope with the caller name above the spend row', async () => {
const { renderer } = await mountLoaded();

const rows = byType(renderer, 'KvRow');
expect(rows).toHaveLength(2);
expect(rows[0]?.props.label).toBe(i18n.t('preferences.account'));
expect(rows[0]?.props.value).toBe('Ada');
// The owner row is not the group's last, so the divider separates it from
// the spend row that closes the group.
expect(rows[0]?.props.last).toBeFalsy();
expect(rows[1]?.props.label).toBe(i18n.t('spendAlerts.spend24h'));
expect(rows[1]?.props.last).toBe(true);
});

it('names an organization scope with the organization name', async () => {
boundary.org = { organizationId: 'org-1' };
getQueryFn.mockResolvedValue(
settingsFixture({ scope: 'organization', scopeName: 'Acme Corp' })
);

const { renderer } = await mountScreen('org-1');
await waitFor(() => byType(renderer, 'KvRow').length === 2);

expect(byType(renderer, 'KvRow')[0]?.props.label).toBe(i18n.t('common.organization'));
expect(byType(renderer, 'KvRow')[0]?.props.value).toBe('Acme Corp');
});

it('keeps the scope line while the master switch is off, so the owner is still named', async () => {
const { renderer } = await mountLoaded({ enabled: false });

expect(kvRowByLabel(renderer, i18n.t('preferences.account'))?.props.value).toBe('Ada');
});
});

describe('SpendAlertsScreen rule descriptions', () => {
it('says what each rule measures on this scope', async () => {
const { renderer } = await mountLoaded();

// The copy the two cards render, pinned: a reader must be able to tell that
// the threshold measures this scope's rolling spend over the chosen window
// and the anomaly measures this scope's hourly rate against its own p95.
expect(i18n.t('spendAlerts.thresholdSubtitle')).toBe(
"This scope's rolling spend over the chosen window crosses your limit"
);
expect(i18n.t('spendAlerts.anomalySubtitle')).toBe(
"This scope's hourly rate is far above its own p95 baseline"
);
const copy = texts(renderer);
expect(copy).toContain(i18n.t('spendAlerts.thresholdSubtitle'));
expect(copy).toContain(i18n.t('spendAlerts.anomalySubtitle'));
// The panel subtitle says the alert watches this scope's own spend.
expect(i18n.t('spendAlerts.subtitle')).toContain("this scope's own spend");
});
});

Expand Down
19 changes: 18 additions & 1 deletion apps/mobile/src/components/organization/spend-alerts-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ export function SpendAlertsScreen({ organizationId }: SpendAlertsScreenProps) {
>
<SpendAlertsForm
organizationId={organizationId}
scope={query.data.scope}
scopeName={query.data.scopeName}
enabled={query.data.enabled ?? false}
rules={query.data.rules}
spend={query.data.spend}
Expand All @@ -182,6 +184,10 @@ export function SpendAlertsScreen({ organizationId }: SpendAlertsScreenProps) {

type SpendAlertsFormProps = Readonly<{
organizationId?: string;
/** The scope the router read: the caller's own account or an organization. */
scope: SpendAlertSettings['scope'];
/** The owner's display name for that scope (the caller's name, or the org's). */
scopeName: string;
enabled: boolean;
rules: SpendAlertRules;
spend: SpendAlertSpend;
Expand All @@ -191,6 +197,8 @@ type SpendAlertsFormProps = Readonly<{

function SpendAlertsForm({
organizationId,
scope,
scopeName,
enabled: storedEnabled,
rules,
spend,
Expand All @@ -201,6 +209,11 @@ function SpendAlertsForm({
const { t } = useTranslation();
const save = useSaveSpendAlerts();

// The screen serves both the caller's own account and an organization; the
// copy alone never says which one an alert watches, so the scope line names
// the owner. The labels are the shared Account / Organization keys.
const scopeLabel = scope === 'organization' ? t('common.organization') : t('preferences.account');

const threshold = rules?.find(rule => rule.kind === 'threshold');
const anomaly = rules?.find(rule => rule.kind === 'anomaly');

Expand Down Expand Up @@ -411,6 +424,7 @@ function SpendAlertsForm({
</Text>

<View className="rounded-lg bg-secondary px-3">
<KvRow label={scopeLabel} value={scopeName} />
<KvRow label={t('spendAlerts.spend24h')} value={spendValue} last />
</View>

Expand Down Expand Up @@ -659,7 +673,10 @@ function SpendAlertsSkeleton() {
<ScreenHeader title={t('notifications.channel.spend')} />
<ScrollView className="flex-1" contentContainerClassName="px-6 gap-6 pb-8 pt-4">
<Skeleton className="h-4 w-64 rounded" />
<Skeleton className="h-[52px] rounded-lg" />
{/* The scope group holds two KvRows now (the scope owner and the 24 h
spend); each row is py-3 (24) plus a text-sm line (20) = 44, so the
block reserves 88 and the loaded rows swap in without moving Save. */}
<Skeleton className="h-[88px] rounded-lg" />
<Skeleton className="h-[56px] rounded-lg" />
<RuleCardSkeleton />
<RuleCardSkeleton />
Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3213,12 +3213,12 @@
"openLastSession": "Open last session"
},
"spendAlerts": {
"subtitle": "Email or push an alert to the billing contacts when this scope's spend crosses a limit or spikes above its usual hourly rate.",
"subtitle": "Email or push an alert to the billing contacts when this scope's own spend crosses a limit or spikes above its usual hourly rate.",
"enable": "Enable spend alerts",
"thresholdTitle": "Threshold crossing",
"thresholdSubtitle": "Rolling spend crosses your limit",
"thresholdSubtitle": "This scope's rolling spend over the chosen window crosses your limit",
"anomalyTitle": "Hourly spike",
"anomalySubtitle": "An hour is far above this scope's usual rate",
"anomalySubtitle": "This scope's hourly rate is far above its own p95 baseline",
"limitLabel": "Limit (USD)",
"windowLabel": "Window",
"window24h": "24 hours",
Expand Down
56 changes: 4 additions & 52 deletions apps/web/src/app/api/cron/dispatch-spend-alerts/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,13 @@ jest.mock('@/lib/spend-alerts/sweep', () => ({
createSpendAlertSweepStore: jest.fn(() => ({ loadScopeSnapshot: jest.fn() })),
runSpendAlertSweep: jest.fn(),
}));
jest.mock('@/lib/spend-alerts/delivery', () => ({
drainPendingSpendAlertDeliveries: jest.fn(),
spendAlertDeliveryDeps: { sendEmail: jest.fn(), dispatchPush: jest.fn() },
}));
const mockSentryLog = jest.fn();
jest.mock('@/lib/utils.server', () => ({ sentryLogger: jest.fn(() => mockSentryLog) }));

import { runSpendAlertSweep } from '@/lib/spend-alerts/sweep';
import { drainPendingSpendAlertDeliveries } from '@/lib/spend-alerts/delivery';
import { GET, maxDuration } from './route';

const mockRunSpendAlertSweep = jest.mocked(runSpendAlertSweep);
const mockDrainPendingSpendAlertDeliveries = jest.mocked(drainPendingSpendAlertDeliveries);

function request(secret: string): NextRequest {
return new NextRequest('http://localhost:3000/api/cron/dispatch-spend-alerts', {
Expand All @@ -35,11 +29,6 @@ describe('GET /api/cron/dispatch-spend-alerts', () => {
cleared: 1,
deliveriesEnqueued: 3,
});
mockDrainPendingSpendAlertDeliveries.mockResolvedValue({
claimed: 3,
delivered: 3,
failed: [],
});
});

test('rejects invalid cron authorization', async () => {
Expand All @@ -48,61 +37,24 @@ describe('GET /api/cron/dispatch-spend-alerts', () => {
expect(response.status).toBe(401);
await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' });
expect(mockRunSpendAlertSweep).not.toHaveBeenCalled();
expect(mockDrainPendingSpendAlertDeliveries).not.toHaveBeenCalled();
});

test('sweeps then drains with a bounded limit and reports the summary', async () => {
test('sweeps and reports the scopesTouched and alertsFired summary', async () => {
const response = await GET(request('cron-secret'));

expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
success: true,
partialFailure: false,
summary: { scopesTouched: 2, alertsFired: 3, delivered: 3, failed: 0 },
summary: { scopesTouched: 2, alertsFired: 3 },
});
expect(mockRunSpendAlertSweep).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ store: expect.anything() }),
expect.objectContaining({ now: expect.any(Date) })
);
expect(mockDrainPendingSpendAlertDeliveries).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
{ limit: 50 }
);
expect(mockRunSpendAlertSweep.mock.invocationCallOrder[0]).toBeLessThan(
mockDrainPendingSpendAlertDeliveries.mock.invocationCallOrder[0]
);
expect(mockSentryLog).toHaveBeenCalledWith(
'Spend alert sweep and delivery drain completed',
expect.objectContaining({ scopesTouched: 2, alertsFired: 3, delivered: 3, failed: 0 })
);
});

test('surfaces partialFailure when a delivery fails', async () => {
mockDrainPendingSpendAlertDeliveries.mockResolvedValue({
claimed: 3,
delivered: 2,
failed: [
{
deliveryId: '00000000-0000-4000-8000-000000000001',
channel: 'email',
error: 'spend_alert_email_delivery_failed',
},
],
});

const response = await GET(request('cron-secret'));

expect(response.status).toBe(500);
await expect(response.json()).resolves.toMatchObject({
success: false,
partialFailure: true,
summary: { delivered: 2, failed: 1 },
});
expect(mockSentryLog).toHaveBeenCalledWith(
'Spend alert delivery completed with partial failures',
expect.objectContaining({ failedCount: 1 })
'Spend alert sweep completed',
expect.objectContaining({ scopesTouched: 2, alertsFired: 3 })
);
});

Expand Down
50 changes: 12 additions & 38 deletions apps/web/src/app/api/cron/dispatch-spend-alerts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,22 @@ import { NextResponse } from 'next/server';
import { db } from '@/lib/drizzle';
import { CRON_SECRET } from '@/lib/config.server';
import { createSpendAlertSweepStore, runSpendAlertSweep } from '@/lib/spend-alerts/sweep';
import {
drainPendingSpendAlertDeliveries,
spendAlertDeliveryDeps,
} from '@/lib/spend-alerts/delivery';
import { isCronAuthorizationValid } from '@/lib/cron-auth';
import { sentryLogger } from '@/lib/utils.server';

if (!CRON_SECRET) {
throw new Error('CRON_SECRET is not configured in environment variables');
}

/**
* Bounded per-run send fan-out. A crossing enqueues at most one row per channel
* and rule, and the pending index orders the claim, so the limit bounds alerts
* sent per run rather than scopes observed.
*/
const SPEND_ALERT_DELIVERY_LIMIT = 50;

export const maxDuration = 300;

/**
* One spend-alert tick: re-derive the hourly buckets for the usage delta,
* decide those scopes (plus any rule still firing), then drain the alert
* outbox. The sweep never walks the owner population, so an idle fleet costs a
* single empty range scan and an empty claim.
* One spend-alert sweep tick: re-derive the hourly buckets for the usage delta
* and decide those scopes (plus any rule still firing). The sweep never walks
* the owner population, so an idle fleet costs a single empty range scan.
*
* Delivery runs in its own cron (`drain-spend-alert-deliveries`) so a slow or
* killed sweep cannot stop the outbox from draining that tick.
*/
export async function GET(request: Request) {
const authHeader = request.headers.get('authorization');
Expand All @@ -47,34 +38,17 @@ export async function GET(request: Request) {
{ store: createSpendAlertSweepStore(db) },
{ now: new Date() }
);
const deliveries = await drainPendingSpendAlertDeliveries(db, spendAlertDeliveryDeps, {
limit: SPEND_ALERT_DELIVERY_LIMIT,
});

const summary = {
scopesTouched: sweep.candidateScopes,
alertsFired: sweep.fired,
delivered: deliveries.delivered,
failed: deliveries.failed.length,
};

sentryLogger('cron', 'info')('Spend alert sweep and delivery drain completed', summary);
sentryLogger('cron', 'info')('Spend alert sweep completed', summary);

const hasFailures = summary.failed > 0;
if (hasFailures) {
sentryLogger('cron', 'error')('Spend alert delivery completed with partial failures', {
failedCount: summary.failed,
failures: deliveries.failed,
});
}

return NextResponse.json(
{
success: !hasFailures,
partialFailure: hasFailures,
summary,
timestamp: new Date().toISOString(),
},
{ status: hasFailures ? 500 : 200 }
);
return NextResponse.json({
success: true,
summary,
timestamp: new Date().toISOString(),
});
}
Loading