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
1 change: 1 addition & 0 deletions pointer/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
logs/
48 changes: 48 additions & 0 deletions pointer/app/api/alerts/status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* GET /api/alerts/status
*
* Returns the current alarm state for all alerts that apply to the current
* user's active subscription. Includes the alert configuration so the client
* has name/threshold context alongside the status.
*/

import { headers } from "next/headers";

import { auth } from "@/lib/auth";
import {
listActiveAlarmsForSubscription,
listAlerts,
listApplicableAlertsForSubscription,
} from "@/lib/alerts/provider";
import { resolveEntitlementSubject } from "@/lib/entitlements/subject";

export async function GET(): Promise<Response> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return Response.json({ error: "unauthorized" }, { status: 401 });

const subject = await resolveEntitlementSubject(session, {
customerType: "user",
});
if (!subject) return Response.json({ alarms: [], alerts: [] });

const [alarms, applicableAlerts, allAlerts] = await Promise.all([
listActiveAlarmsForSubscription(subject.chargebeeSubscriptionId),
listApplicableAlertsForSubscription(subject.chargebeeSubscriptionId),
listAlerts(),
]);

// Merge applicable + all alerts for enrichment (applicable may be empty for
// global alerts that haven't been linked to the subscription yet).
const alertMap = new Map([
...allAlerts.map((a) => [a.id, a] as [string, typeof a]),
...applicableAlerts.map((a) => [a.id, a] as [string, typeof a]),
]);

// Enrich each alarm with the alert config for display.
const enriched = alarms.map((alarm) => ({
...alarm,
alert: alertMap.get(alarm.alert_id) ?? null,
}));

return Response.json({ alarms: enriched, total: enriched.length });
}
72 changes: 72 additions & 0 deletions pointer/app/usage/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import type { Metadata } from "next";
import { headers } from "next/headers";
import { redirect } from "next/navigation";

import type { Alert, AlertStatus } from "chargebee";

import { auth } from "@/lib/auth";
import {
listActiveAlarmsForSubscription,
listAlerts,
listApplicableAlertsForSubscription,
} from "@/lib/alerts/provider";
import { resolveEntitlements } from "@/lib/entitlements/features";
import { getUsageSnapshot } from "@/lib/entitlements/gate";
import { resolveEntitlementSubject } from "@/lib/entitlements/subject";
Expand Down Expand Up @@ -33,6 +40,31 @@ type History =
| { status: "disabled" }
| { status: "unavailable" };

/** Fetch active alarms for the subscription (best-effort, never throws). */
async function loadAlarms(
subscriptionId: string,
): Promise<Array<AlertStatus & { alert: Alert | null }>> {
try {
const [alarms, applicableAlerts, allAlerts] = await Promise.all([
listActiveAlarmsForSubscription(subscriptionId),
listApplicableAlertsForSubscription(subscriptionId),
listAlerts(),
]);
// Merge applicable + all for enrichment — global alerts may not be in
// the "applicable" list until Chargebee links them to the subscription.
const alertMap = new Map([
...allAlerts.map((a) => [a.id, a] as [string, typeof a]),
...applicableAlerts.map((a) => [a.id, a] as [string, typeof a]),
]);
return alarms.map((alarm) => ({
...alarm,
alert: alertMap.get(alarm.alert_id) ?? null,
}));
} catch {
return [];
}
}

/** One aggregate per metered feature — each reduces to a different column. */
async function loadHistory(
subscriptionId: string,
Expand Down Expand Up @@ -82,6 +114,7 @@ export default async function UsagePage({ searchParams }: PageProps<"/usage">) {
entitlements.limits,
);
const history = await loadHistory(subject.chargebeeSubscriptionId, range);
const activeAlarms = await loadAlarms(subject.chargebeeSubscriptionId);

return (
<main className="mx-auto w-full max-w-6xl px-6 pb-16">
Expand All @@ -103,6 +136,45 @@ export default async function UsagePage({ searchParams }: PageProps<"/usage">) {
</p>
) : null}

{activeAlarms.length > 0 && (
<div className="mt-6 space-y-2">
{activeAlarms.map((alarm) => {
const name = alarm.alert?.name ?? `Alert ${alarm.alert_id}`;
const threshold = alarm.alert?.threshold;
const thresholdStr = threshold
? threshold.mode === "percentage"
? `${threshold.value}%`
: String(threshold.value)
: null;
return (
<div
key={alarm.alert_id}
role="alert"
className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-200"
>
<span className="mt-0.5 shrink-0 text-base leading-none" aria-hidden>
⚠️
</span>
<div>
<span className="font-semibold">{name}</span>
{thresholdStr && (
<span className="ml-1 text-red-600 dark:text-red-400">
— threshold of {thresholdStr} exceeded
</span>
)}
{alarm.alarm_triggered_at && (
<span className="ml-2 text-xs text-red-500 dark:text-red-400">
since{" "}
{new Date(alarm.alarm_triggered_at * 1000).toLocaleString()}
</span>
)}
</div>
</div>
);
})}
</div>
)}

<div className="mt-8">
<RangeTabs active={range.key} />
</div>
Expand Down
152 changes: 152 additions & 0 deletions pointer/lib/alerts/provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* Thin wrapper around the Chargebee alert and alert_status APIs.
*
* Alert types:
* usage_exceeded — monitors a metered feature; percentage or absolute
* spend_exceeded — monitors overage spend in a currency; absolute only
* credit_balance_dropped — monitors a credit unit balance; absolute only
*
* Scope:
* global (subscription_id omitted) — applies to all subscriptions, optionally
* filtered by plan_price_id via filter_conditions
* subscription-scoped — applies to one subscription only
*/

import type { Alert, AlertStatus } from "chargebee";

import { getRedis } from "@/lib/redis";
import { chargebeeClient } from "@/plugins/chargebee-plugin";

// ---------------------------------------------------------------------------
// Local alarm state — Redis cache keyed by subscription_id.
// Written by the worker when it processes alert_status_changed webhooks so the
// usage page shows alarm banners even before Chargebee's evaluation engine
// has propagated the status to the alertStatus API.
// ---------------------------------------------------------------------------

const ALARM_KEY_PREFIX = "pointer:alarm:";
/** TTL = 25 hours, covering the 24-hour billing evaluation window + margin. */
const ALARM_TTL_SECONDS = 25 * 60 * 60;

export type LocalAlarmEntry = {
alert_id: string;
subscription_id: string;
alarm_status: "in_alarm" | "ok";
alarm_triggered_at?: number;
alert_name?: string;
alert_type?: string;
metered_feature_id?: string;
};

export async function setLocalAlarm(entry: LocalAlarmEntry): Promise<void> {
const redis = getRedis();
const key = `${ALARM_KEY_PREFIX}${entry.subscription_id}:${entry.alert_id}`;
if (entry.alarm_status === "ok") {
await redis.del(key);
} else {
await redis.set(key, JSON.stringify(entry), "EX", ALARM_TTL_SECONDS);
}
}

export async function getLocalAlarms(
subscriptionId: string,
): Promise<LocalAlarmEntry[]> {
const redis = getRedis();
const pattern = `${ALARM_KEY_PREFIX}${subscriptionId}:*`;
const keys = await redis.keys(pattern);
if (!keys.length) return [];
const values = await redis.mget(...keys);
return values
.filter(Boolean)
.map((v) => JSON.parse(v!) as LocalAlarmEntry)
.filter((e) => e.alarm_status === "in_alarm");
}

export type { Alert, AlertStatus };

export async function listAlerts(): Promise<Alert[]> {
const result = await chargebeeClient.alert.list({ limit: 100 });
return result.list.map((entry) => entry.alert);
}

export async function listApplicableAlertsForSubscription(
subscriptionId: string,
): Promise<Alert[]> {
const result = await (
chargebeeClient.alert as unknown as {
application_alertsForSubscription: (
id: string,
) => Promise<{ list: { alert: Alert }[] }>;
}
).application_alertsForSubscription(subscriptionId);
return result.list.map((entry) => entry.alert);
}

// alertStatus property exists at runtime but the SDK type file has a typo,
// and the method name uses snake_case with the SDK convention.
const alertStatusResource = (chargebeeClient as unknown as {
alertStatus: {
alert_statusesForSubscription: (
subscriptionId: string,
params?: { limit?: number; alarm_status?: { is?: string } },
) => Promise<{ list: { alert_status: AlertStatus }[] }>;
};
}).alertStatus;

export async function listAlertStatusesForSubscription(
subscriptionId: string,
): Promise<AlertStatus[]> {
const result = await alertStatusResource.alert_statusesForSubscription(
subscriptionId,
{ limit: 100 },
);
return result.list.map((entry) => entry.alert_status);
}

export async function listActiveAlarmsForSubscription(
subscriptionId: string,
): Promise<AlertStatus[]> {
// Fetch ALL statuses from Chargebee (not just in_alarm) so we can detect
// when Chargebee has resolved an alarm that's still cached in Redis.
const [cbResult, localAlarms] = await Promise.all([
alertStatusResource
.alert_statusesForSubscription(subscriptionId, { limit: 100 })
.catch(() => ({ list: [] as { alert_status: AlertStatus }[] })),
getLocalAlarms(subscriptionId),
]);

const allCbStatuses = cbResult.list.map((entry) => entry.alert_status);
const cbAlarms = allCbStatuses.filter((s) => s.alarm_status === "in_alarm");

// Build a set of alert_ids Chargebee explicitly marks as "ok".
// These override any stale Redis entry — evict them from Redis asynchronously.
const cbOkAlertIds = new Set(
allCbStatuses
.filter((s) => s.alarm_status === "ok")
.map((s) => s.alert_id),
);
for (const la of localAlarms) {
if (cbOkAlertIds.has(la.alert_id)) {
// Chargebee says resolved — clear the stale Redis key (fire-and-forget).
setLocalAlarm({ ...la, alarm_status: "ok" }).catch(() => undefined);
}
}

// Redis fills the gap only for alarms Chargebee hasn't propagated yet
// (not in the Chargebee response at all — neither in_alarm nor ok).
const cbKnownAlertIds = new Set(allCbStatuses.map((s) => s.alert_id));
const localOnly = localAlarms
.filter((la) => !cbKnownAlertIds.has(la.alert_id))
.map(
(la) =>
({
alert_id: la.alert_id,
subscription_id: la.subscription_id,
alarm_status: la.alarm_status,
alarm_triggered_at: la.alarm_triggered_at,
object: "alert_status",
}) as AlertStatus,
);

return [...cbAlarms, ...localOnly];
}
Loading
Loading