import { type Context, Hono } from 'hono' import * as z from 'zod/mini' import * as Analytics from '../../../analytics/Analytics.js' import * as ApiKeys from '../../../ApiKeys.js' import * as Auth from '../../../internal/Auth.js' import * as BillingSettings from '../../../db/tables/billingSettings.js' import * as Db from '../../../db/Db.js' import * as OpenApi from '../../../internal/OpenApi.js' import * as RequestEvents from '../../../analytics/tables/requestEvents.js' import * as Response from '../../../internal/Response.js' import * as Schema from '../../../internal/Schema.js' import * as SponsoredTransactions from '../../../db/tables/sponsoredTransactions.js' import * as core_Billing from '../Billing.js' import type { Environment } from '../App.js' /** Zod schemas owned by the usage resource. */ export namespace schema { /** Schemas for the getRequestUsage operation. */ export namespace getRequestUsage { /** Usage grouped by API key. */ export const KeyBreakdown = OpenApi.component( z .object({ apiKeyId: z .string() .check( z.describe('API key id (`key_…`).'), z.meta({ examples: ['key_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), averageDurationMs: z .number() .check( z.describe('Average request duration in milliseconds.'), z.meta({ examples: [12.3] }), ), errors: z .number() .check(z.int(), z.describe('Requests whose status was at least 400.'), z.meta({ examples: [3] })), // prettier-ignore requests: z .number() .check(z.int(), z.describe('Total requests.'), z.meta({ examples: [120] })), }) .check(z.describe('Usage grouped by API key.')), 'RequestUsageByKey', ) /** Path parameters addressing one organization's usage. */ export const Params = z .object({ orgId: z .string() .check( z.describe('The organization id (`org_…`).'), z.meta({ examples: ['org_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }) .check(z.describe("Path parameters for an organization's usage.")) /** Query parameters for request usage. */ export const Query = z .strictObject({ environment: z .optional(z.enum(['production', 'sandbox'])) .check(z.describe('Only include requests made with this API-key environment.'), z.meta({ examples: ['sandbox'] })), // prettier-ignore from: z .optional(z.iso.datetime({ offset: true })) .check( z.describe('Only include requests at or after this ISO 8601 timestamp.'), z.meta({ examples: ['2026-01-01T00:00:00Z'] }), ), interval: z ._default(z.enum(['day', 'hour']), 'day') .check( z.describe('Time bucket size for the usage series.'), z.meta({ examples: ['day'] }), ), projectId: z.optional(z.string()).check( z.describe('Only include requests attributed to this project (`prj_…`). Omit for organization-wide usage.'), // prettier-ignore z.meta({ examples: ['prj_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), to: z .optional(z.iso.datetime({ offset: true })) .check( z.describe('Only include requests at or before this ISO 8601 timestamp.'), z.meta({ examples: ['2026-01-31T23:59:59Z'] }), ), }) .check(z.describe('Query parameters for request usage.')) /** Usage grouped by route. */ export const RouteBreakdown = OpenApi.component( z .object({ averageDurationMs: z .number() .check( z.describe('Average request duration in milliseconds.'), z.meta({ examples: [18.2] }), ), errors: z .number() .check(z.int(), z.describe('Requests whose status was at least 400.'), z.meta({ examples: [2] })), // prettier-ignore requests: z .number() .check(z.int(), z.describe('Total requests.'), z.meta({ examples: [80] })), route: z .string() .check( z.describe('Matched route pattern.'), z.meta({ examples: ['/v1/transactions/:hash'] }), ), }) .check(z.describe('Usage grouped by route.')), 'RequestUsageByRoute', ) /** One usage series point. */ export const SeriesPoint = OpenApi.component( z .object({ errors: z .number() .check(z.int(), z.describe('Requests whose status was at least 400.'), z.meta({ examples: [1] })), // prettier-ignore requests: z .number() .check(z.int(), z.describe('Total requests.'), z.meta({ examples: [40] })), time: z.iso .datetime() .check( z.describe('Bucket start timestamp (ISO 8601).'), z.meta({ examples: ['2026-01-01T00:00:00.000Z'] }), ), }) .check(z.describe('One usage series point.')), 'RequestUsageSeriesPoint', ) /** Usage grouped by status code. */ export const StatusBreakdown = OpenApi.component( z .object({ requests: z .number() .check(z.int(), z.describe('Total requests.'), z.meta({ examples: [100] })), status: z .number() .check(z.int(), z.describe('HTTP status code.'), z.meta({ examples: [200] })), }) .check(z.describe('Usage grouped by status code.')), 'RequestUsageByStatus', ) /** Overall usage totals. */ export const Totals = OpenApi.component( z .object({ averageDurationMs: z .number() .check( z.describe('Average request duration in milliseconds.'), z.meta({ examples: [15.6] }), ), errors: z .number() .check(z.int(), z.describe('Requests whose status was at least 400.'), z.meta({ examples: [5] })), // prettier-ignore requests: z .number() .check(z.int(), z.describe('Total requests.'), z.meta({ examples: [200] })), }) .check(z.describe('Overall usage totals.')), 'RequestUsageTotals', ) /** Request usage. */ export const Response = OpenApi.component( Schema.describe( z.object({ byKey: z.array(KeyBreakdown).check(z.describe('Usage grouped by API key.'), z.meta({ examples: [[]] })), // prettier-ignore byRoute: z.array(RouteBreakdown).check(z.describe('Usage grouped by route.'), z.meta({ examples: [[]] })), // prettier-ignore byStatus: z.array(StatusBreakdown).check(z.describe('Usage grouped by status code.'), z.meta({ examples: [[]] })), // prettier-ignore from: z.iso .datetime() .check( z.describe('Inclusive lower timestamp bound used for the read (ISO 8601).'), z.meta({ examples: ['2026-01-01T00:00:00.000Z'] }), ), interval: z .enum(['day', 'hour']) .check( z.describe('Bucket size used for the time series.'), z.meta({ examples: ['day'] }), ), series: z.array(SeriesPoint).check(z.describe('Usage grouped into time buckets.'), z.meta({ examples: [[]] })), // prettier-ignore to: z.iso .datetime() .check( z.describe('Inclusive upper timestamp bound used for the read (ISO 8601).'), z.meta({ examples: ['2026-01-31T23:59:59.000Z'] }), ), totals: Totals, }), 'Request usage.', ), 'RequestUsage', ) } /** Schemas for the getSponsorshipUsage operation. */ export namespace getSponsorshipUsage { /** One time bucket of sponsorship usage. */ export const Bucket = OpenApi.component( Schema.describe( z.object({ count: z .number() .check( z.describe('Sponsored transactions recorded in the bucket, any status.'), z.meta({ examples: [42] }), ), failed: z .number() .check( z.describe('Sponsored transactions in the bucket whose status is `failed`.'), z.meta({ examples: [3] }), ), feeTotal: z .object({ amount: z.string().check( z.describe('Committed fees as a decimal string: finalized fees plus in-flight fee caps; failed rows contribute zero.'), // prettier-ignore z.meta({ examples: ['12.34'] }), ), currency: z .enum(BillingSettings.currencies) .check(z.describe('Currency of the fee figure.'), z.meta({ examples: ['usd'] })), }) .check( z.describe('Committed fees for the bucket.'), z.meta({ examples: [{ amount: '12.34', currency: 'usd' }] }), ), timestamp: z.iso .datetime() .check( z.describe('Bucket start (ISO 8601), aligned to UTC calendar boundaries.'), z.meta({ examples: ['2026-01-01T00:00:00.000Z'] }), ), }), 'One time bucket of sponsorship usage.', ), 'SponsorshipUsageBucket', ) /** Path parameters addressing one organization's usage. */ export const Params = z .object({ orgId: z .string() .check( z.describe('The organization id (`org_…`).'), z.meta({ examples: ['org_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }) .check(z.describe("Path parameters for one organization's usage.")) /** Query parameters scoping the sponsorship usage series. */ export const Query = z .strictObject({ environment: z .optional(z.enum(['production', 'sandbox'])) .check( z.describe('Only include sponsorships requested under this key environment.'), z.meta({ examples: ['production'] }), ), from: z .optional(z.iso.datetime({ offset: true })) .check( z.describe('Window start (ISO 8601), inclusive. Defaults to 30 days before `to`.'), z.meta({ examples: ['2026-01-01T00:00:00Z'] }), ), interval: z ._default(z.enum(['day', 'hour', 'month', 'week']), 'day') .check( z.describe('Bucket width, aligned to UTC calendar boundaries.'), z.meta({ examples: ['day'] }), ), projectId: z .optional(z.string()) .check( z.describe('Only include sponsorships attributed to this project (`prj_…`).'), z.meta({ examples: ['prj_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), to: z .optional(z.iso.datetime({ offset: true })) .check( z.describe('Window end (ISO 8601), exclusive. Defaults to now.'), z.meta({ examples: ['2026-01-31T00:00:00Z'] }), ), }) .check(z.describe('Query parameters scoping the sponsorship usage series.')) /** Sponsorship usage bucketed over time. */ export const Response = OpenApi.component( Schema.describe( z.object({ data: z .array(Bucket) .check( z.describe('Time-ordered usage buckets; buckets with no sponsorships are omitted.'), z.meta({ examples: [[]] }), ), }), 'Sponsorship usage bucketed over time.', ), 'SponsorshipUsage', ) } } /** Mounts organization request and sponsorship usage. Reads require membership or a scoped API key. */ export function usage() { return new Hono() .get( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/usage/requests', Auth.policy({ apiKey: { scopes: ['management:read'] }, session: true }), Auth.ensureOrg(), OpenApi.validate('param', schema.getRequestUsage.Params, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.validate('query', schema.getRequestUsage.Query, { code: 'query_invalid', message: 'Check the query parameters and try again.', }), OpenApi.describeRoute({ operationId: 'getRequestUsage', responses: OpenApi.responses({ errors: { 400: { codes: ['param_invalid', 'query_invalid'], description: 'Malformed API key, invalid path, or invalid query.', }, 404: { codes: ['organization_not_found'], description: 'No accessible organization was found for this id.', }, 501: { codes: ['analytics_unconfigured'], description: 'Usage analytics is not configured on this deployment.', }, }, success: { description: 'The request usage.', schema: schema.getRequestUsage.Response, }, }), summary: 'Get request usage', tags: ['Usage'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (Auth.narrowScope) return Auth.ensureOrgError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'param_invalid', message: 'Check the path parameters and try again.', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Check the query parameters and try again.', }) const analytics = c.get('analytics') if (!analytics) return analyticsUnconfigured(c) const query = c.req.valid('query') const to = new Date(Date.parse(query.to ?? new Date().toISOString())).toISOString() const lookback = query.interval === 'day' ? 30 * dayMs : 24 * hourMs const from = new Date(Date.parse(query.from ?? new Date(Date.parse(to) - lookback).toISOString())).toISOString() // prettier-ignore if (Date.parse(from) > Date.parse(to)) return Response.error(c, { code: 'query_invalid', message: '`from` must be before or equal to `to`.', status: 400, }) if (Date.parse(to) - Date.parse(from) > (maxBuckets - 1) * intervalMs(query.interval)) return Response.error(c, { code: 'query_invalid', message: `Usage series are limited to ${maxBuckets} buckets.`, status: 400, }) try { const environment = query.environment === undefined ? {} : { environment: query.environment } const keyIds = await legacyKeyIds(c, { environment: query.environment, projectId: query.projectId, }) return c.json( Response.validated( schema.getRequestUsage.Response, await RequestEvents.readProjectUsage(Analytics.get(analytics), { attributions: [ // `projectId` present scopes to one project; absent covers the // whole org. Either way legacy null-project rows are folded in // by explicit key id below. { ...environment, ...(query.projectId === undefined ? {} : { projectId: query.projectId }), }, ...(keyIds.length > 0 ? [{ apiKeyIds: keyIds, ...environment, projectId: null }] : []), ], from, interval: query.interval, orgId: Auth.org(c).id, to, }), ), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .get( '/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/usage/sponsorships', Auth.policy({ apiKey: { scopes: ['management:read'] }, session: true }), Auth.ensureOrg(), OpenApi.validate('param', schema.getSponsorshipUsage.Params, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.validate('query', schema.getSponsorshipUsage.Query, { code: 'query_invalid', message: 'Check the query parameters and try again.', }), OpenApi.describeRoute({ operationId: 'getSponsorshipUsage', responses: OpenApi.responses({ errors: { 400: { codes: ['param_invalid', 'query_invalid'], description: 'Malformed API key, invalid path, or invalid query.', }, 404: { codes: ['organization_not_found'], description: 'No accessible organization was found for this id.', }, }, success: { description: "The organization's sponsorship usage series.", schema: schema.getSponsorshipUsage.Response, }, }), summary: 'Get sponsorship usage', tags: ['Usage'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (Auth.narrowScope) return Auth.ensureOrgError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'param_invalid', message: 'Check the path parameters and try again.', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Check the query parameters and try again.', }) const query = c.req.valid('query') const to = query.to ? new Date(query.to) : new Date() const from = query.from ? new Date(query.from) : new Date(to.getTime() - 30 * dayMs) if (from.getTime() >= to.getTime()) return windowInvalid(c, '`from` must be before `to`') // Bound the scan, not just the output: 31 days of hours is 744 buckets; // wider intervals cap at 400 days regardless of bucket count. const maxDays = query.interval === 'hour' ? 31 : 400 if (to.getTime() - from.getTime() > maxDays * dayMs) return windowInvalid( c, `The window may span at most ${maxDays} days at \`${query.interval}\` interval`, ) try { const buckets = await SponsoredTransactions.usage(Db.get(c.get('dbCached')), { environment: query.environment, from: from.toISOString(), interval: query.interval, orgId: Auth.org(c).id, projectId: query.projectId, to: to.toISOString(), }) // Sponsored fees settle in USD: the relay refuses non-USD fee tokens // on mainnet and rows snapshot `currency`, so one constant is correct // until multi-currency sponsorship ships (then group by currency). const currency = 'usd' return c.json( Response.validated(schema.getSponsorshipUsage.Response, { data: buckets.map((bucket) => ({ count: bucket.count, failed: bucket.failed, feeTotal: { amount: core_Billing.fromBaseUnits(bucket.feeTotal), currency }, timestamp: bucket.timestamp, })), }), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) } /** One day in milliseconds; window defaults and caps derive from it. */ const dayMs = 24 * 60 * 60 * 1000 /** One hour in milliseconds. */ const hourMs = 60 * 60 * 1000 /** Upper bound on request-usage series length. */ const maxBuckets = 200 /** API-key ids minted before project attribution; matched by explicit id. */ async function legacyKeyIds( c: Context, options: { environment: 'production' | 'sandbox' | undefined projectId: string | undefined }, ): Promise { const kv = c.get('kv') if (!kv) return [] const records = await ApiKeys.listByOrg(kv.store, Auth.org(c).id, { ...(options.projectId === undefined ? {} : { projectId: options.projectId }), scopeCatalog: c.get('scopeCatalog'), }) return records .filter( (record) => options.environment === undefined || record.environment === options.environment, ) // prettier-ignore .map((record) => record.id) } function analyticsUnconfigured(c: Context) { return Response.error(c, { code: 'analytics_unconfigured', message: 'Usage analytics is not configured on this deployment', status: 501, }) } function intervalMs(interval: 'day' | 'hour') { return interval === 'day' ? dayMs : hourMs } function windowInvalid(c: Context, message: string) { return Response.error(c, { code: 'query_invalid', message, status: 400 }) }