usage.ts123 lines · main
1import { Hono } from 'hono';
2
3import { requireProjectAuth } from '../middleware/project-auth.js';
4import { fetchProjectRealtimeStats } from '../services/realtime-stats.js';
5import {
6 getConnectionSecondsUsage,
7 getCurrentMonthConnectionSecondsUsage,
8 getCurrentMonthInvocationUsage,
9 getInvocationUsage,
10 getStorageUsage,
11} from '../services/usage.js';
12import { getProjectTier, TIERS } from '../services/tiers.js';
13import type { ProjectAppEnv as AppEnv } from '../types/app-env.js';
14
15export const usageRouter = new Hono<AppEnv>();
16
17usageRouter.use('/v1/projects/:id/usage', requireProjectAuth());
18usageRouter.use('/v1/projects/:id/realtime-stats', requireProjectAuth());
19
20/**
21 * Current-period usage for a project. Default period = current calendar
22 * month UTC. Pass `?from=…&until=…` (ISO 8601) to query a custom
23 * window — useful for billing reconciliation or historical lookups,
24 * subject to function_logs retention (free tier: 7 days).
25 *
26 * Phase 3 follow-ups (deferred):
27 * - DB-size / storage-bytes / RT-connection-minutes signals
28 * - usage_rollups table for periods beyond log retention
29 * - Polar metering API push
30 */
31usageRouter.get('/v1/projects/:id/usage', async (c) => {
32 const projectId = c.req.param('id');
33 const fromParam = c.req.query('from');
34 const untilParam = c.req.query('until');
35
36 let invocations;
37 let connection;
38 let periodStart: string;
39 let periodEnd: string;
40 if (fromParam || untilParam) {
41 if (!fromParam || !untilParam) {
42 return c.json(
43 { code: 'validation_failed', message: 'from and until must be provided together' },
44 400,
45 );
46 }
47 const from = new Date(fromParam);
48 const until = new Date(untilParam);
49 if (Number.isNaN(from.getTime()) || Number.isNaN(until.getTime())) {
50 return c.json(
51 { code: 'validation_failed', message: 'from and until must be ISO 8601 timestamps' },
52 400,
53 );
54 }
55 if (until <= from) {
56 return c.json(
57 { code: 'validation_failed', message: 'until must be after from' },
58 400,
59 );
60 }
61 invocations = await getInvocationUsage(projectId, from, until);
62 connection = await getConnectionSecondsUsage(projectId, from, until);
63 periodStart = from.toISOString();
64 periodEnd = until.toISOString();
65 } else {
66 invocations = await getCurrentMonthInvocationUsage(projectId);
67 connection = await getCurrentMonthConnectionSecondsUsage(projectId);
68 periodStart = invocations.periodStart;
69 periodEnd = invocations.periodEnd;
70 }
71
72 // Surface the project's tier + monthly cap alongside the raw count so
73 // the dashboard widget needs only one round-trip. Soft cap — over-cap
74 // doesn't block invokes today; rate-limit middleware enforces the
75 // per-request floor (services/tiers.ts: RATE_LIMITS_BY_TIER).
76 const tier = (await getProjectTier(projectId)) ?? 'free';
77 const limits = TIERS[tier];
78
79 // Storage is sampled live (single round-trip to the data plane) — at
80 // 25-customer scale the cost is negligible. If it ever becomes a hot
81 // path the natural cache is a `usage_rollups` snapshot updated every
82 // 5 min by the same cron that will push to Polar metering.
83 const storage = await getStorageUsage(projectId);
84
85 return c.json({
86 projectId,
87 periodStart,
88 periodEnd,
89 tier,
90 invocations: {
91 count: invocations.count,
92 totalDurationMs: invocations.totalDurationMs,
93 },
94 storage: {
95 bytes: storage.bytes,
96 tableCount: storage.tableCount,
97 sampledAt: storage.sampledAt,
98 },
99 connection: {
100 seconds: connection.seconds,
101 },
102 limits: {
103 invokesPerMonth: limits.invokesPerMonth,
104 storageBytes: limits.storageBytes,
105 connectionSecondsPerMonth: limits.connectionSecondsPerMonth,
106 concurrentSubscriptions: limits.concurrentSubscriptions,
107 },
108 });
109});
110
111/**
112 * Live realtime usage for the requested project — scoped to the caller's
113 * own project so a non-admin owner can see their own concurrent-sub
114 * count vs cap without enumerating other projects. Returns 503 when the
115 * realtime service is unconfigured/unreachable; the dashboard treats that
116 * as "—" rather than rendering a stale or zeroed banner.
117 */
118usageRouter.get('/v1/projects/:id/realtime-stats', async (c) => {
119 const projectId = c.req.param('id');
120 const stats = await fetchProjectRealtimeStats(projectId);
121 if (!stats) return c.json({ code: 'realtime_unavailable' }, 503);
122 return c.json({ projectId, ...stats });
123});