Skip to content
Closed
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
60 changes: 60 additions & 0 deletions node-src/tasks/verify/getAccountUsage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from 'vitest';

import TestLogger from '../../lib/testLogger';
import { getAccountUsage } from './getAccountUsage';

const currentUsage = {
totalFinalBillableCount: 42_500,
from: 1_789_167_000_000,
to: 1_791_759_000_000,
};

const deps = (result: unknown) =>
({
client: {
runQuery: vi.fn(() =>
result instanceof Error ? Promise.reject(result) : Promise.resolve(result)
),
},
log: new TestLogger(),
}) as any;

describe('getAccountUsage', () => {
it('returns usage against the monthly limit plus bonus usage', async () => {
const usage = await getAccountUsage(
deps({
app: {
account: { currentUsage, subscription: { monthlyUsageLimit: 80_000, bonusUsage: 5000 } },
},
})
);
expect(usage).toEqual({
billableSnapshots: 42_500,
limit: 85_000,
utilization: 0.5,
periodStart: currentUsage.from,
periodEnd: currentUsage.to,
});
});

it('omits the limit and utilization when the plan has no limit', async () => {
const usage = await getAccountUsage(
deps({ app: { account: { currentUsage, subscription: { monthlyUsageLimit: null } } } })
);
expect(usage).toMatchObject({
billableSnapshots: 42_500,
limit: undefined,
utilization: undefined,
});
});

it('returns undefined when the account has no current usage', async () => {
expect(await getAccountUsage(deps({ app: { account: null } }))).toBeUndefined();
});

it('returns undefined instead of throwing when the query fails', async () => {
const d = deps(new Error('Not authorized'));
expect(await getAccountUsage(d)).toBeUndefined();
expect(d.log.debug).toHaveBeenCalledWith(expect.stringContaining('Not authorized'));
});
});
74 changes: 74 additions & 0 deletions node-src/tasks/verify/getAccountUsage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { AccountUsage, Deps } from '../../types';

const AccountUsageQuery = `
query AccountUsageQuery {
app {
account {
currentUsage {
totalFinalBillableCount
from
to
}
subscription {
monthlyUsageLimit
bonusUsage
}
}
}
}
`;

interface AccountUsageQueryResult {
app: {
account?: {
currentUsage?: {
totalFinalBillableCount: number;
from: number;
to: number;
} | null;
subscription?: {
monthlyUsageLimit?: number | null;
bonusUsage?: number | null;
} | null;
} | null;
};
}

/**
* Fetch the account's billed snapshot usage for the current billing period.
*
* This is informational only, so any failure (including the API not exposing these fields to the
* project token) is logged at debug level and never affects the build.
*
* @param deps Dependencies (client, log).
*
* @returns The account's usage, or undefined if it is unavailable.
*/
export async function getAccountUsage(
deps: Pick<Deps, 'client' | 'log'>
): Promise<AccountUsage | undefined> {
try {
const { app } = await deps.client.runQuery<AccountUsageQueryResult>(
AccountUsageQuery,
{},
{ retries: 0 }
);
const usage = app.account?.currentUsage;
if (!usage) {
return undefined;
}

const { monthlyUsageLimit, bonusUsage } = app.account?.subscription ?? {};
const limit = monthlyUsageLimit ? monthlyUsageLimit + (bonusUsage ?? 0) : undefined;
return {
billableSnapshots: usage.totalFinalBillableCount,
limit,
utilization: limit ? usage.totalFinalBillableCount / limit : undefined,
periodStart: usage.from,
periodEnd: usage.to,
};
} catch (err) {
deps.log.debug(`Could not fetch account usage: ${err.message}`);
return undefined;
}
}
13 changes: 13 additions & 0 deletions node-src/tasks/verify/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ describe('applyVerifyOutput', () => {
expect(ctx.exitCode).toBe(undefined);
});

it('applies account usage to context', () => {
const ctx = {} as any;
const accountUsage = {
billableSnapshots: 10,
limit: 100,
utilization: 0.1,
periodStart: 1,
periodEnd: 2,
};
applyVerifyOutput(ctx, { ...baseOutput, accountUsage });
expect(ctx.accountUsage).toEqual(accountUsage);
});

it('applies a limit exit code', () => {
const ctx = {} as any;
applyVerifyOutput(ctx, {
Expand Down
21 changes: 19 additions & 2 deletions node-src/tasks/verify/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { exitCodes, setExitCode } from '../../lib/setExitCode';
import { Context, Deps, TaskResult } from '../../types';
import { AccountUsage, Context, Deps, TaskResult } from '../../types';
import accountUsageMessage from '../../ui/messages/info/accountUsage';
import { getAccountUsage } from './getAccountUsage';
import { publishBuild } from './publishBuild';
import { verifyBuild } from './verifyBuild';

Expand All @@ -22,6 +24,7 @@ export interface VerifyOutput {
isPublishOnly: boolean;
skipSnapshots: boolean;
limitExitCode?: { code: number; userError: boolean };
accountUsage?: AccountUsage;
}

/**
Expand Down Expand Up @@ -60,9 +63,22 @@ export async function verifyProject(
isReactNativeApp: input.isReactNativeApp,
});

const accountUsage = await getAccountUsage(deps);
if (accountUsage) {
deps.log.info(accountUsageMessage(accountUsage));
}

return {
kind: 'continue',
output: { announcedBuild, storybookUrl, build, isPublishOnly, skipSnapshots, limitExitCode },
output: {
announcedBuild,
storybookUrl,
build,
isPublishOnly,
skipSnapshots,
limitExitCode,
accountUsage,
},
};
}

Expand All @@ -82,6 +98,7 @@ export const applyVerifyOutput = (ctx: Context, output: VerifyOutput) => {
ctx.storybookUrl = output.storybookUrl;
ctx.build = output.build;
ctx.isPublishOnly = output.isPublishOnly;
ctx.accountUsage = output.accountUsage;

// Ordering on the setExitCode calls matters: a limit code first, then OK overrides it when the
// build is publish-only / listed / exiting once uploaded. If we add a third case, refactor.
Expand Down
15 changes: 15 additions & 0 deletions node-src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,20 @@ export interface VisitedCommit {
/** Whether the clone has full or truncated commit history. */
export type CloneDepth = 'full' | 'shallow';

/** The account's billed snapshot usage for the current billing period. */
export interface AccountUsage {
/** Billed snapshots so far this period (fractional, as TurboSnaps bill at a multiplier). */
billableSnapshots: number;
/** Snapshots included in the plan (monthly limit plus bonus), if the plan has a limit. */
limit?: number;
/** `billableSnapshots / limit`, where 1 means the included snapshots are used up. */
utilization?: number;
/** Start of the billing period, as a timestamp in milliseconds. */
periodStart: number;
/** End of the billing period, as a timestamp in milliseconds. */
periodEnd: number;
}

/**
* Whether git objects are fully present or filtered at clone time.
* - `'full'` — all objects fetched (standard clone)
Expand Down Expand Up @@ -419,6 +433,7 @@ export interface Context {
environment?: Record<string, string>;
reportPath?: string;
isPublishOnly?: boolean;
accountUsage?: AccountUsage;
isOnboarding: boolean;
isReactNativeApp?: boolean;
turboSnapAvailability?: string;
Expand Down
25 changes: 25 additions & 0 deletions node-src/ui/messages/info/accountUsage.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import accountUsage from './accountUsage';

export default {
title: 'CLI/Messages/Info',
component: accountUsage,
};

const period = { periodStart: 1_789_167_000_000, periodEnd: 1_791_759_000_000 };

export const AccountUsageWithinLimit = {
args: { billableSnapshots: 42_317.4, limit: 85_000, utilization: 42_317.4 / 85_000, ...period },
};

export const AccountUsageOverLimit = {
args: {
billableSnapshots: 1_036_922.6,
limit: 85_000,
utilization: 1_036_922.6 / 85_000,
...period,
},
};

export const AccountUsageWithoutLimit = {
args: { billableSnapshots: 1_036_922.6, ...period },
};
26 changes: 26 additions & 0 deletions node-src/ui/messages/info/accountUsage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import chalk from 'chalk';
import { dedent } from 'ts-dedent';

import { AccountUsage } from '../../../types';
import { info } from '../../components/icons';

const count = (value: number) => Math.round(value).toLocaleString('en-US');
const date = (timestamp: number) => new Date(timestamp).toISOString().slice(0, 10);

export default ({
billableSnapshots,
limit,
utilization,
periodStart,
periodEnd,
}: AccountUsage) => {
const usage =
limit && utilization !== undefined
? `${count(billableSnapshots)} / ${count(limit)} (${count(utilization * 100)}%)`
: count(billableSnapshots);

return dedent(chalk`
${info} {bold Snapshot usage}: ${usage}
Billing period: ${date(periodStart)} → ${date(periodEnd)}
`);
};