Files
triggerdotdev--trigger.dev/apps/webapp/app/presenters/OrgUsagePresenter.server.ts
Matt Aitken 72f3a4b128 Usage and billing (v3) (#1201)
* v3 subscription endpoints

* Use pnpm linked billing package during development

* Moved v2 billing components into a subfolder

* Select plan using the real data

* Improved v3 plan display

* Use new api response that doesn’t require a Stripe call

* Free flow is working

* Added GitHub modal and verified badge

* Deleted old request v3 access component/route

* Allow setting classes on the Tooltip button

* Paid plans working

* Redirect from select plan if you’ve got v3 enabled

* Loading state improvements

* New admin API endpoint to set concurrency across multiple environments

* When projects are created, conditionally create staging based on the plan

* New billing page working with side menu and stripe portal

* Layout, formatting and some plan state improvements/fixes

* Don’t show the period if you’re on the free plan

* Temporary upgrade callout

* Refactored the platform code so it’s easier to call and doesn’t require a isManagedCloud check

* Send taskIdentifier to OpenMeter

* Side menu

* Upgrade prompts

* More improvements to the app-wide usage indicators

* Early work on usage graphs

* Added the usage bar for v3

* Moved code to presenter and now using defer

* Added the tasks table to usage

* Improved the v3 usage bar if theres’ no usage on a paid plan

* If no run data, still render a graph

* Usage page errors when defered loading fails

* Don’t show the public API key for v3, they’re not used and probably never will be

* Improved the upgrade callout and API keys page layout

* Only show the “reveal all” toggle if you have environment variables in the table

* Replaced Upgrade callout with a more generic InfoPanel component

* better panel width

* Show conditional upgrade prompts based on plan and number of schedules used

* Removed duplicate class

* Wider blank state panels for the scheduled page

* Wider info panel for the env var page

* Blank state now using the info panel

* Platform alerts prompt now using the InfoPanel

* Deploy blank state uses InfoPanel

* Github verified badge padding adjustment

* Improved the layout of the page, some style tweaks, organized imports

* Better default tooltip style

* could be undefined fix

* text fix + style updates

* Changed the billing icon in the side menu

* Billing page layout and style improvements

* plan tooltips don’t use dark variant

* Don’t highlight the plan on the billing page

* Tooltip underlines stand out more

* Fixed padding in the PageTitle

* Tooltips use the correct cursor

* Improved the plan banner on the billing page

* Fixed Header1 inconsistent font weight

* Fixed issue where input field focus states were being clipped

* Fixed large button not having large text size

* Added a link to the Get in touch copy and improved the connect to GitHub modal

* Select plan page uses the MainCenteredContainer

* Better logging from the Loops endpoint because this error finally got hit

* Move the ingestion of compute to the platform

* Reporting usage of invocations moved to the platform

* Get the entitlement before triggering a non-dev task

* Contact us enterprise plan button opens the feedback form

* Removed Github discussions link from the Feedback panel

* Swapped billing icon for credit card

* Show a Unlock staging panel on the env var page

* Updated staging environment colour

* Show a prompt to upgrade to get staging in the new env var modal

* Improved the edit env var modal

* Implement ability to disable org concurrency

* Use common logic for the plans

* Use the billing server to get the schedule limits

* Some schedules page fixes

* More convenient way of getting a limit

* Use the new schedule limit

* Team member limiting

* Made the limit visible on the team page

* Limit alerts

* Added an index for TaskRun.scheduleId

* Remove console.log on schedules page

* Added durations to the run table

* Tabular numbers

* Improved the usage page formatting

* Only admins see the compute column on the run table

* Include the base cost on the usage stats

* Moved the status to the sidebar

* Optional table header tooltip

* Allow InfoIconTooltips to have customizable content styles

* Added a tooltip to the duration header, changed no test to a dash

* Removed all references to signing up to v3 from the docs

* Switched @trigger.dev/billing to @trigger.dev/platform

* Passing up the variant for the InfoIconTooltip

* table tooltip max-width fixed

* Switch to the published @trigger.dev/platform 1.0.11

* v2 usage page title changed to include “v2"

* code theme has a transparent background so it works on any background

* duration columns now grouped together nicely at wide screen size

* Last duration column fills the width properly

* Fix for the per run price being in cents not dollars

* Show the total cost with 8 decimal places

* Show 8 decimal places in the usage graph tooltip

* Moved the UpgradePrompt to the v3 folder

* Prepare to use Shadcns chart helpers

* Much nicer chart

* Small tweaks to the graph

* Fix run table col spans for empty/loading messages

* We don’t need isManagedCloud in createProject

* Hide v3 usage/billing pages if there aren’t v3 projects in your org

* Removed unused tooltipStyle

* Usage bar now says “Included usage” instead of “Tier limit” if you’re paying

* Get the plan/usage data in parallel

* The usage page now has a month dropdown and all data is for that calendar month

* Ensure the passed date is the 1st of the month

* Use the machine presets from the platform package

---------

Co-authored-by: James Ritchie <james@jamesritchie.co.uk>
Co-authored-by: Eric Allam <eallam@icloud.com>
2024-07-08 14:42:19 +01:00

281 lines
9.3 KiB
TypeScript

import { estimate } from "@trigger.dev/platform/v2";
import { sqlDatabaseSchema } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { BillingService } from "~/services/billing.v2.server";
import { BasePresenter } from "./v3/basePresenter.server";
export class OrgUsagePresenter extends BasePresenter {
public async call({ userId, slug, request }: { userId: string; slug: string; request: Request }) {
const organization = await this._replica.organization.findFirst({
where: {
slug,
members: {
some: {
userId,
},
},
},
});
if (!organization) {
throw new Error("Organization not found");
}
// Get count of runs since the start of the current month
const runsCount = await this._replica.jobRun.count({
where: {
organizationId: organization.id,
createdAt: {
gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
},
internal: false,
},
});
// Get the count of the runs for the last 6 months, by month. So for example we want the data shape to be:
// [
// { month: "2021-01", count: 10 },
// { month: "2021-02", count: 20 },
// { month: "2021-03", count: 30 },
// { month: "2021-04", count: 40 },
// { month: "2021-05", count: 50 },
// { month: "2021-06", count: 60 },
// ]
// This will be used to generate the chart on the usage page
// Use prisma queryRaw for this since prisma doesn't support grouping by month
const monthlyRunsDataRaw = await this._replica.$queryRaw<
{
month: string;
count: number;
}[]
>`SELECT TO_CHAR("createdAt", 'YYYY-MM') as month, COUNT(*) as count FROM ${sqlDatabaseSchema}."JobRun" WHERE "organizationId" = ${organization.id} AND "createdAt" >= NOW() - INTERVAL '6 months' AND "internal" = FALSE GROUP BY month ORDER BY month ASC`;
const hasMonthlyRunData = monthlyRunsDataRaw.length > 0;
const monthlyRunsData = monthlyRunsDataRaw.map((obj) => ({
name: obj.month,
total: Number(obj.count), // Convert BigInt to Number
}));
const monthlyRunsDataDisplay = fillInMissingRunMonthlyData(monthlyRunsData, 6);
// Max concurrency each day over past 30 days
const concurrencyChartRawData = await this._replica.$queryRaw<
{ day: Date; max_concurrent_runs: BigInt }[]
>`
WITH time_boundaries AS (
SELECT generate_series(
NOW() - interval '30 days',
NOW(),
interval '1 day'
) AS day_start
),
events AS (
SELECT
day_start,
event_time,
event_type,
SUM(event_type) OVER (ORDER BY event_time) AS running_total
FROM
time_boundaries
JOIN
triggerdotdev_events.run_executions
ON
event_time >= day_start AND event_time < day_start + interval '1 day'
WHERE triggerdotdev_events.run_executions.organization_id = ${organization.id}
),
max_concurrent_per_day AS (
SELECT
date_trunc('day', event_time) AS day,
MAX(running_total) AS max_concurrent_runs
FROM
events
GROUP BY day
)
SELECT
day,
max_concurrent_runs
FROM
max_concurrent_per_day
ORDER BY
day;`;
const ThirtyDaysAgo = new Date();
ThirtyDaysAgo.setDate(ThirtyDaysAgo.getDate() - 30);
ThirtyDaysAgo.setUTCHours(0, 0, 0, 0);
const hasConcurrencyData = concurrencyChartRawData.length > 0;
const concurrencyChartRawDataFilledIn = fillInMissingConcurrencyDays(
ThirtyDaysAgo,
31,
concurrencyChartRawData
);
const dailyRunsRawData = await this._replica.$queryRaw<
{ day: Date; runs: BigInt }[]
>`SELECT date_trunc('day', "createdAt") as day, COUNT(*) as runs FROM ${sqlDatabaseSchema}."JobRun" WHERE "organizationId" = ${organization.id} AND "createdAt" >= NOW() - INTERVAL '30 days' AND "internal" = FALSE GROUP BY day`;
const hasDailyRunsData = dailyRunsRawData.length > 0;
const dailyRunsDataFilledIn = fillInMissingDailyRuns(ThirtyDaysAgo, 31, dailyRunsRawData);
const endOfMonth = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1);
endOfMonth.setDate(endOfMonth.getDate() - 1);
const projectedRunsCount = Math.round(
runsCount / (new Date().getDate() / endOfMonth.getDate())
);
const { isManagedCloud } = featuresForRequest(request);
const billingPresenter = new BillingService(isManagedCloud);
const plans = await billingPresenter.getPlans();
let runCostEstimation: number | undefined = undefined;
let projectedRunCostEstimation: number | undefined = undefined;
if (plans) {
const estimationResult = estimate({
usage: { runs: runsCount },
plans: [plans.free, plans.paid],
});
runCostEstimation = estimationResult?.cost.runsCost;
const projectedEstimationResult = estimate({
usage: { runs: projectedRunsCount },
plans: [plans.free, plans.paid],
});
projectedRunCostEstimation = projectedEstimationResult?.cost.runsCost;
}
const periodStart = new Date();
periodStart.setDate(1);
periodStart.setUTCHours(0, 0, 0, 0);
const periodEnd = new Date();
periodEnd.setDate(1);
periodEnd.setMonth(periodEnd.getMonth() + 1);
periodEnd.setUTCHours(0, 0, 0, 0);
return {
id: organization.id,
runsCount,
projectedRunsCount,
monthlyRunsData: monthlyRunsDataDisplay,
hasMonthlyRunData,
concurrencyData: concurrencyChartRawDataFilledIn,
hasConcurrencyData,
dailyRunsData: dailyRunsDataFilledIn,
hasDailyRunsData,
runCostEstimation,
projectedRunCostEstimation,
periodStart,
periodEnd,
};
}
}
// This will fill in missing chart data with zeros
// So for example, if data is [{ name: "2021-01", total: 10 }, { name: "2021-03", total: 30 }] and the totalNumberOfMonths is 6
// And the current month is "2021-04", then this function will return:
// [{ name: "2020-11", total: 0 }, { name: "2020-12", total: 0 }, { name: "2021-01", total: 10 }, { name: "2021-02", total: 0 }, { name: "2021-03", total: 30 }, { name: "2021-04", total: 0 }]
function fillInMissingRunMonthlyData(
data: Array<{ name: string; total: number }>,
totalNumberOfMonths: number
): Array<{ name: string; total: number }> {
const currentMonth = new Date().toISOString().slice(0, 7);
const startMonth = new Date(
new Date(currentMonth).getFullYear(),
new Date(currentMonth).getMonth() - (totalNumberOfMonths - 2),
1
)
.toISOString()
.slice(0, 7);
const months = getMonthsBetween(startMonth, currentMonth);
let completeData = months.map((month) => {
let foundData = data.find((d) => d.name === month);
return foundData ? { ...foundData } : { name: month, total: 0 };
});
return completeData;
}
function fillInMissingConcurrencyDays(
startDate: Date,
days: number,
data: Array<{ day: Date; max_concurrent_runs: BigInt }>
) {
const outputData: Array<{ date: Date; maxConcurrentRuns: number }> = [];
for (let i = 0; i < days; i++) {
const date = new Date(startDate);
date.setDate(date.getDate() + i);
const foundData = data.find((d) => d.day.toISOString() === date.toISOString());
if (!foundData) {
outputData.push({
date,
maxConcurrentRuns: 0,
});
} else {
outputData.push({
date,
maxConcurrentRuns: Number(foundData.max_concurrent_runs),
});
}
}
return outputData;
}
function fillInMissingDailyRuns(
startDate: Date,
days: number,
data: Array<{ day: Date; runs: BigInt }>
) {
const outputData: Array<{ date: Date; runs: number }> = [];
for (let i = 0; i < days; i++) {
const date = new Date(startDate);
date.setDate(date.getDate() + i);
const foundData = data.find((d) => d.day.toISOString() === date.toISOString());
if (!foundData) {
outputData.push({
date,
runs: 0,
});
} else {
outputData.push({
date,
runs: Number(foundData.runs),
});
}
}
return outputData;
}
// Start month will be like 2023-03 and endMonth will be like 2023-10
// The result should be an array of months between these two months, including the start and end month
// So for example, if startMonth is 2023-03 and endMonth is 2023-10, the result should be:
// ["2023-03", "2023-04", "2023-05", "2023-06", "2023-07", "2023-08", "2023-09", "2023-10"]
function getMonthsBetween(startMonth: string, endMonth: string): string[] {
// Initialize result array
const result: string[] = [];
// Parse the year and month from startMonth and endMonth
let [startYear, startMonthNum] = startMonth.split("-").map(Number);
let [endYear, endMonthNum] = endMonth.split("-").map(Number);
// Loop through each month between startMonth and endMonth
for (let year = startYear; year <= endYear; year++) {
let monthStart = year === startYear ? startMonthNum : 1;
let monthEnd = year === endYear ? endMonthNum : 12;
for (let month = monthStart; month <= monthEnd; month++) {
// Format the month into a string and add it to the result array
result.push(`${year}-${String(month).padStart(2, "0")}`);
}
}
return result;
}