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
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export const fireTrackWebhooks = ({
ctx,
oldFullCus,
newFullCus,
oldFullSubject,
newFullSubject,
feature: affectedFeature,
entityId,
}).catch((error) => {
Expand Down
328 changes: 47 additions & 281 deletions server/src/internal/balances/usageAlerts/check/checkUsageAlerts.ts
Original file line number Diff line number Diff line change
@@ -1,305 +1,71 @@
import {
type ApiBalanceV1,
AppEnv,
type DbUsageAlert,
type Feature,
type FullCustomer,
fullCustomerToCustomerEntitlements,
fullCustomerToPlanProducts,
fullCustomerToTags,
getApiBalance,
getPlanBillingControlProducts,
WebhookEventType,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { sendSvixEvent } from "@/external/svix/svixHelpers.js";
import type { Feature, FullCustomer, FullSubject } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";

export type AlertScope = "customer" | "entity" | "org" | "plan";

const usageAlertsForFeature = ({
alerts,
feature,
}: {
alerts: DbUsageAlert[];
feature: Feature;
}) =>
alerts.filter(
(alert) => alert.feature_id === feature.id || !alert.feature_id,
);

/**
* Customer-level alerts use the aggregate customer balance (no entity scope).
* Plan-level alerts are a fallback at the same scope, applied only when the
* customer has no own alert for the feature.
*/
export const resolveCustomerScopeAlerts = ({
fullCustomer,
feature,
}: {
fullCustomer: FullCustomer;
feature: Feature;
}): { alerts: DbUsageAlert[]; scope: AlertScope } => {
const customerAlerts = usageAlertsForFeature({
alerts: fullCustomer.usage_alerts ?? [],
feature,
});
if (customerAlerts.length > 0) {
return { alerts: customerAlerts, scope: "customer" };
}

const planProduct = getPlanBillingControlProducts({
customerProducts: fullCustomerToPlanProducts({ fullCustomer }),
}).find(
(customerProduct) =>
usageAlertsForFeature({
alerts: customerProduct.product?.usage_alerts ?? [],
feature,
}).length > 0,
);

return {
alerts: usageAlertsForFeature({
alerts: planProduct?.product?.usage_alerts ?? [],
feature,
}),
scope: "plan",
};
};

export const wasThresholdCrossed = ({
alert,
oldApiBalance,
newApiBalance,
}: {
alert: DbUsageAlert;
oldApiBalance: ApiBalanceV1;
newApiBalance: ApiBalanceV1;
}) => {
// Unlimited balances report real usage now, but alerts on them never fired
// when usage was masked to 0 — keep that semantic: no usage alerts for
// unlimited features.
if (oldApiBalance.unlimited || newApiBalance.unlimited) return false;

if (alert.threshold_type === "usage") {
const shldAlert =
oldApiBalance.usage < alert.threshold &&
newApiBalance.usage >= alert.threshold;

return shldAlert;
}

if (alert.threshold_type === "remaining_percentage") {
if (oldApiBalance.granted <= 0 || newApiBalance.granted <= 0) return false;

const currentRemainingPercentage = new Decimal(newApiBalance.remaining)
.div(newApiBalance.granted)
.mul(100)
.toNumber();

const oldRemainingPercentage = new Decimal(oldApiBalance.remaining)
.div(oldApiBalance.granted)
.mul(100)
.toNumber();

return (
currentRemainingPercentage <= alert.threshold &&
oldRemainingPercentage > alert.threshold
);
}

if (alert.threshold_type === "remaining") {
const currentRemaining = newApiBalance.remaining;
const oldRemaining = oldApiBalance.remaining;

return (
currentRemaining <= alert.threshold && oldRemaining > alert.threshold
);
}

// usage_percentage
if (alert.threshold_type === "usage_percentage") {
if (oldApiBalance.granted <= 0 || newApiBalance.granted <= 0) return false;

const oldPercentage = new Decimal(oldApiBalance.usage)
.div(oldApiBalance.granted)
.mul(100)
.toNumber();
const newPercentage = new Decimal(newApiBalance.usage)
.div(newApiBalance.granted)
.mul(100)
.toNumber();

return oldPercentage < alert.threshold && newPercentage >= alert.threshold;
}

return false;
};

const processAlerts = async ({
ctx,
oldFullCus,
newFullCus,
feature,
entityId,
alerts,
scope,
}: {
ctx: AutumnContext;
oldFullCus: FullCustomer;
newFullCus: FullCustomer;
feature: Feature;
entityId?: string;
alerts: DbUsageAlert[];
scope: AlertScope;
}) => {
if (!alerts || alerts.length === 0) return;

const matchingAlerts = usageAlertsForFeature({ alerts, feature }).filter(
(alert) => alert.enabled,
);

if (matchingAlerts.length === 0) return;

const entity = entityId
? newFullCus.entities?.find((e) => e.id === entityId)
: undefined;

const oldCustomerEntitlements = fullCustomerToCustomerEntitlements({
fullCustomer: oldFullCus,
featureId: feature.id,
entity,
});

const newCustomerEntitlements = fullCustomerToCustomerEntitlements({
fullCustomer: newFullCus,
featureId: feature.id,
entity,
});

const { data: oldApiBalance } = getApiBalance({
ctx,
fullCus: oldFullCus,
cusEnts: oldCustomerEntitlements,
feature,
});
const { data: newApiBalance } = getApiBalance({
ctx,
fullCus: newFullCus,
cusEnts: newCustomerEntitlements,
feature,
});

for (const alert of matchingAlerts) {
if (
!wasThresholdCrossed({
alert,
oldApiBalance,
newApiBalance,
})
)
continue;

const customerId = newFullCus.id || newFullCus.internal_id;

const minuteBucket = Math.floor(Date.now() / 60_000);
const idempotencyKey = [
ctx.org.id,
ctx.env,
customerId,
entityId ?? "_",
scope,
feature.id,
alert.threshold_type,
alert.threshold,
minuteBucket,
].join(":");

const tags = fullCustomerToTags({ fullCustomer: newFullCus });

await sendSvixEvent({
ctx,
eventType: WebhookEventType.BalancesUsageAlertTriggered,
idempotencyKey,
data: {
customer_id: customerId,
feature_id: feature.id,
...(entityId && { entity_id: entityId }),
usage_alert: {
name: alert.name,
threshold: alert.threshold,
threshold_type: alert.threshold_type,
},
},
tags,
});

ctx.logger.info(
`Usage alert triggered (scope=${scope}) for customer ${customerId}, feature ${feature.id}, threshold ${alert.threshold} (${alert.threshold_type})${entityId ? `, entity ${entityId}` : ""}`,
);
}
};
import { measureUsageAlert } from "./measure/measureUsageAlert.js";
import { resolveScopeApiBalances } from "./measure/resolveScopeApiBalances.js";
import { resolveAlertScopes } from "./resolve/resolveAlertScopes.js";
import { sendUsageAlertWebhook } from "./send/sendUsageAlertWebhook.js";
import type { TrackedSubjects } from "./types/trackedSubjects.js";
import { wasThresholdCrossed } from "./wasThresholdCrossed.js";

export const checkUsageAlerts = async ({
ctx,
oldFullCus,
newFullCus,
oldFullSubject,
newFullSubject,
feature,
entityId,
}: {
ctx: AutumnContext;
oldFullCus: FullCustomer;
newFullCus: FullCustomer;
oldFullSubject?: FullSubject;
newFullSubject?: FullSubject;
feature: Feature;
entityId?: string;
}) => {
// 1. Customer-level alerts (aggregate balance, no entity scope), falling
// back to plan-level alerts when the customer has none for this feature.
const customerScope = resolveCustomerScopeAlerts({
fullCustomer: newFullCus,
feature,
});
await processAlerts({
}): Promise<void> => {
const tracked: TrackedSubjects = {
before: { fullCustomer: oldFullCus, fullSubject: oldFullSubject },
after: { fullCustomer: newFullCus, fullSubject: newFullSubject },
};
const alertScopes = resolveAlertScopes({
ctx,
oldFullCus,
newFullCus,
fullCustomer: tracked.after.fullCustomer,
feature,
alerts: customerScope.alerts,
scope: customerScope.scope,
entityId,
});

// 2. Entity-level alerts fire additionally, scoped to the entity's balance.
if (entityId) {
const entity = newFullCus.entities?.find((e) => e.id === entityId);
await processAlerts({
ctx,
oldFullCus,
newFullCus,
feature,
entityId,
alerts: usageAlertsForFeature({
alerts: entity?.usage_alerts ?? [],
feature,
}),
scope: "entity",
});
}
for (const scopedAlerts of alertScopes) {
const alerts = scopedAlerts.alerts.filter((alert) => alert.enabled);
if (alerts.length === 0) continue;

// 3. Org-level alerts apply to all customers and use the tracked subject.
// Env-scoped: sandbox reads sandbox_usage_alerts, live reads usage_alerts.
const orgAlerts =
ctx.env === AppEnv.Sandbox
? (ctx.org.config?.sandbox_usage_alerts ?? [])
: (ctx.org.config?.usage_alerts ?? []);
if (orgAlerts.length > 0) {
await processAlerts({
const apiBalances = resolveScopeApiBalances({
ctx,
oldFullCus,
newFullCus,
tracked,
feature,
entityId,
alerts: orgAlerts,
scope: "org",
entityId: scopedAlerts.entityId,
});

for (const alert of alerts) {
const measured = measureUsageAlert({
ctx,
alert,
feature,
tracked,
apiBalances,
entityId: scopedAlerts.entityId,
});
if (!measured || !wasThresholdCrossed({ alert, ...measured })) continue;

await sendUsageAlertWebhook({
ctx,
fullCustomer: tracked.after.fullCustomer,
feature,
alert,
scope: scopedAlerts.scope,
entityId: scopedAlerts.entityId,
measurement: measured.after,
});
}
}
};
Loading
Loading