diff --git a/auth-server/src/app/api/admin/send-daily-report/buildReportHtml.ts b/auth-server/src/app/api/admin/send-daily-report/buildReportHtml.ts
index 239a26f9..24ec2b43 100644
--- a/auth-server/src/app/api/admin/send-daily-report/buildReportHtml.ts
+++ b/auth-server/src/app/api/admin/send-daily-report/buildReportHtml.ts
@@ -3,6 +3,8 @@ import { brandColors } from "@schemavaults/theme";
import type { OrganizationDefinition } from "@schemavaults/auth-common";
import type { TopMostActiveUserRow, UserDocument } from "@/lib/auth-db/users";
import type { ErrorRow } from "@/lib/auth-db/errors";
+import type { TopMostPopularAppRow } from "@/lib/auth-db/apps";
+import type { TopMostPopularApiRow } from "@/lib/auth-db/apis";
interface BuildReportOpts {
authServerUri: string;
@@ -12,6 +14,8 @@ interface BuildReportOpts {
newOrganizations: readonly OrganizationDefinition[];
newErrors: readonly ErrorRow[];
topMostActiveUsers: readonly TopMostActiveUserRow[];
+ topMostPopularApps: readonly TopMostPopularAppRow[];
+ topMostPopularApis: readonly TopMostPopularApiRow[];
}
interface ReportContent {
@@ -46,6 +50,8 @@ export function buildDailyAdminReport({
newOrganizations,
newErrors,
topMostActiveUsers,
+ topMostPopularApps,
+ topMostPopularApis,
}: BuildReportOpts): ReportContent {
const windowLabel = `${formatTimestamp(windowStart.getTime())} → ${formatTimestamp(windowEnd.getTime())}`;
@@ -91,6 +97,34 @@ export function buildDailyAdminReport({
})
.join("\n");
+ const topPopularAppsRows = topMostPopularApps.length === 0
+ ? `
New errors (${newErrors.length})
@@ -229,6 +301,28 @@ ${errorsRows}
});
}
textLines.push("");
+ textLines.push(`Most popular applications (${topMostPopularApps.length}):`);
+ if (topMostPopularApps.length === 0) {
+ textLines.push(" (none)");
+ } else {
+ topMostPopularApps.forEach((a, i) => {
+ textLines.push(
+ ` ${i + 1}. ${a.app_name} [${a.client_app_id}] — ${a.access_token_count.toLocaleString("en-US")} access / ${a.refresh_token_count.toLocaleString("en-US")} refresh`,
+ );
+ });
+ }
+ textLines.push("");
+ textLines.push(`Most popular APIs (${topMostPopularApis.length}):`);
+ if (topMostPopularApis.length === 0) {
+ textLines.push(" (none)");
+ } else {
+ topMostPopularApis.forEach((a, i) => {
+ textLines.push(
+ ` ${i + 1}. ${a.api_server_name} [${a.api_server_id}] — ${a.access_token_count.toLocaleString("en-US")} access / ${a.refresh_token_count.toLocaleString("en-US")} refresh`,
+ );
+ });
+ }
+ textLines.push("");
textLines.push(`New errors (${newErrors.length}):`);
if (newErrors.length === 0) {
textLines.push(" (none)");
diff --git a/auth-server/src/app/api/admin/send-daily-report/sendDailyReportHandler.ts b/auth-server/src/app/api/admin/send-daily-report/sendDailyReportHandler.ts
index 91ab3b77..b7766bc4 100644
--- a/auth-server/src/app/api/admin/send-daily-report/sendDailyReportHandler.ts
+++ b/auth-server/src/app/api/admin/send-daily-report/sendDailyReportHandler.ts
@@ -12,6 +12,8 @@ import {
} from "@/lib/auth-db/users";
import { listErrorsCreatedSince } from "@/lib/auth-db/errors";
import { listOrganizationsCreatedSince } from "@/lib/auth-db/organizations";
+import { listTopMostPopularAppsSince } from "@/lib/auth-db/apps";
+import { listTopMostPopularApisSince } from "@/lib/auth-db/apis";
import sendEmailViaMailServer from "@/lib/send-email-via-mail-server";
import captureServerException from "@/lib/captureServerException";
import { buildDailyAdminReport } from "./buildReportHtml";
@@ -34,13 +36,21 @@ export async function sendDailyReportHandler({
const windowEnd = new Date();
const windowStart = new Date(windowEnd.getTime() - TWENTY_FOUR_HOURS_MS);
- const [newUsers, newOrganizations, newErrors, topMostActiveUsers] =
- await Promise.all([
- listUsersCreatedSince(dbh.db, windowStart.getTime()),
- listOrganizationsCreatedSince(dbh.db, windowStart.getTime()),
- listErrorsCreatedSince(dbh.db, windowStart.getTime()),
- listTopMostActiveUsersSince(dbh.db, windowStart.getTime(), 10),
- ]);
+ const [
+ newUsers,
+ newOrganizations,
+ newErrors,
+ topMostActiveUsers,
+ topMostPopularApps,
+ topMostPopularApis,
+ ] = await Promise.all([
+ listUsersCreatedSince(dbh.db, windowStart.getTime()),
+ listOrganizationsCreatedSince(dbh.db, windowStart.getTime()),
+ listErrorsCreatedSince(dbh.db, windowStart.getTime()),
+ listTopMostActiveUsersSince(dbh.db, windowStart.getTime(), 10),
+ listTopMostPopularAppsSince(dbh.db, windowStart.getTime(), 10),
+ listTopMostPopularApisSince(dbh.db, windowStart.getTime(), 10),
+ ]);
const appEnv: SchemaVaultsAppEnvironment = getAppEnvironment();
const authServerUri: string = getAuthServerUri(appEnv);
@@ -53,6 +63,8 @@ export async function sendDailyReportHandler({
newOrganizations,
newErrors,
topMostActiveUsers,
+ topMostPopularApps,
+ topMostPopularApis,
});
const dateLabel = windowEnd.toISOString().slice(0, 10);
@@ -72,6 +84,8 @@ export async function sendDailyReportHandler({
organizations_count: newOrganizations.length,
errors_count: newErrors.length,
top_most_active_users_count: topMostActiveUsers.length,
+ top_most_popular_apps_count: topMostPopularApps.length,
+ top_most_popular_apis_count: topMostPopularApis.length,
window_start: windowStart.toISOString(),
window_end: windowEnd.toISOString(),
});
diff --git a/auth-server/src/lib/auth-db/apis/index.ts b/auth-server/src/lib/auth-db/apis/index.ts
index d854aafe..084b96c5 100644
--- a/auth-server/src/lib/auth-db/apis/index.ts
+++ b/auth-server/src/lib/auth-db/apis/index.ts
@@ -12,3 +12,6 @@ export { preloadApiServersTable } from './preload-api-servers-table';
export type { QueryApiServersInputOptions } from './preload-api-servers-table';
export { default as loadApiServerDefinitionFromDatabase } from './load-api-server-definition-from-db';
+
+export { listTopMostPopularApisSince } from './list-top-most-popular-apis-since';
+export type { TopMostPopularApiRow } from './list-top-most-popular-apis-since';
diff --git a/auth-server/src/lib/auth-db/apis/list-top-most-popular-apis-since.ts b/auth-server/src/lib/auth-db/apis/list-top-most-popular-apis-since.ts
new file mode 100644
index 00000000..c45eb5cd
--- /dev/null
+++ b/auth-server/src/lib/auth-db/apis/list-top-most-popular-apis-since.ts
@@ -0,0 +1,88 @@
+import "server-only";
+import type { Kysely, Transaction } from "@schemavaults/dbh";
+import {
+ isHardcodedApiServerId,
+ getHardcodedApiServer,
+} from "@schemavaults/app-definitions";
+import type { AuthDatabase } from "@/lib/auth-db/auth-database-types";
+import { countTokensIssuedSinceGroupedByColumn } from "@/lib/auth-db/issued-tokens";
+
+export interface TopMostPopularApiRow {
+ api_server_id: string;
+ api_server_name: string;
+ access_token_count: number;
+ refresh_token_count: number;
+}
+
+/**
+ * List the API servers (token audiences) that received the most tokens since
+ * `since_ms`, ranked by access-token count.
+ *
+ * Note: refresh tokens are always issued with the auth server itself as their
+ * audience, so ranking by access tokens keeps the list reflective of actual
+ * resource-API usage rather than refresh-token volume. The refresh count is
+ * still surfaced per row for transparency.
+ */
+export async function listTopMostPopularApisSince(
+ db: Kysely | Transaction,
+ since_ms: number,
+ limit: number = 10,
+): Promise {
+ const countsByAudience = await countTokensIssuedSinceGroupedByColumn(
+ db,
+ since_ms,
+ "audience",
+ );
+
+ const ranked = Array.from(countsByAudience.entries())
+ .map(([api_server_id, c]) => ({
+ api_server_id,
+ access: c.access,
+ refresh: c.refresh,
+ }))
+ .sort((x, y) => {
+ if (y.access !== x.access) return y.access - x.access;
+ if (y.refresh !== x.refresh) return y.refresh - x.refresh;
+ return x.api_server_id < y.api_server_id
+ ? -1
+ : x.api_server_id > y.api_server_id
+ ? 1
+ : 0;
+ })
+ .slice(0, limit);
+
+ if (ranked.length === 0) return [];
+
+ // Resolve human-readable names for the top API servers only.
+ const nameById = new Map();
+ const dbApiServerIds: string[] = [];
+ for (const r of ranked) {
+ if (isHardcodedApiServerId(r.api_server_id)) {
+ nameById.set(
+ r.api_server_id,
+ getHardcodedApiServer(r.api_server_id).api_server_name,
+ );
+ } else {
+ dbApiServerIds.push(r.api_server_id);
+ }
+ }
+ if (dbApiServerIds.length > 0) {
+ const apiRows = await db
+ .selectFrom("api_servers")
+ .where("api_server_id", "in", dbApiServerIds)
+ .select(["api_server_id", "api_server_name"])
+ .execute();
+ for (const row of apiRows) {
+ nameById.set(row.api_server_id, row.api_server_name);
+ }
+ }
+
+ return ranked.map((r) => ({
+ api_server_id: r.api_server_id,
+ api_server_name: nameById.get(r.api_server_id) ?? r.api_server_id,
+ access_token_count: r.access,
+ refresh_token_count: r.refresh,
+ }));
+}
+
+export default listTopMostPopularApisSince;
diff --git a/auth-server/src/lib/auth-db/apps/index.ts b/auth-server/src/lib/auth-db/apps/index.ts
index 0f7fd67d..608b7a71 100644
--- a/auth-server/src/lib/auth-db/apps/index.ts
+++ b/auth-server/src/lib/auth-db/apps/index.ts
@@ -4,3 +4,5 @@ export { AuthorizedAppsRegistry } from './authorized-apps-registry';
export type * from './authorized-apps-registry';
export { getDefinitionForAuthorizedDeclaration } from './get-app-from-authorized-declaration';
export { preloadAppsTable } from './preload-apps-table';
+export { listTopMostPopularAppsSince } from './list-top-most-popular-apps-since';
+export type { TopMostPopularAppRow } from './list-top-most-popular-apps-since';
diff --git a/auth-server/src/lib/auth-db/apps/list-top-most-popular-apps-since.ts b/auth-server/src/lib/auth-db/apps/list-top-most-popular-apps-since.ts
new file mode 100644
index 00000000..9dd30119
--- /dev/null
+++ b/auth-server/src/lib/auth-db/apps/list-top-most-popular-apps-since.ts
@@ -0,0 +1,79 @@
+import "server-only";
+import type { Kysely, Transaction } from "@schemavaults/dbh";
+import { isHardcodedAppId, getHardcodedApp } from "@schemavaults/app-definitions";
+import type { AuthDatabase } from "@/lib/auth-db/auth-database-types";
+import { countTokensIssuedSinceGroupedByColumn } from "@/lib/auth-db/issued-tokens";
+
+export interface TopMostPopularAppRow {
+ client_app_id: string;
+ app_name: string;
+ access_token_count: number;
+ refresh_token_count: number;
+}
+
+/**
+ * List the client applications that issued the most tokens since `since_ms`,
+ * ranked by total tokens (access + refresh). Both token types carry the real
+ * `client_app_id`, so both contribute to an app's popularity.
+ */
+export async function listTopMostPopularAppsSince(
+ db: Kysely | Transaction,
+ since_ms: number,
+ limit: number = 10,
+): Promise {
+ const countsByAppId = await countTokensIssuedSinceGroupedByColumn(
+ db,
+ since_ms,
+ "client_app_id",
+ );
+
+ const ranked = Array.from(countsByAppId.entries())
+ .map(([client_app_id, c]) => ({
+ client_app_id,
+ access: c.access,
+ refresh: c.refresh,
+ total: c.access + c.refresh,
+ }))
+ .sort((x, y) => {
+ if (y.total !== x.total) return y.total - x.total;
+ if (y.access !== x.access) return y.access - x.access;
+ return x.client_app_id < y.client_app_id
+ ? -1
+ : x.client_app_id > y.client_app_id
+ ? 1
+ : 0;
+ })
+ .slice(0, limit);
+
+ if (ranked.length === 0) return [];
+
+ // Resolve human-readable names for the top apps only.
+ const nameById = new Map();
+ const dbAppIds: string[] = [];
+ for (const r of ranked) {
+ if (isHardcodedAppId(r.client_app_id)) {
+ nameById.set(r.client_app_id, getHardcodedApp(r.client_app_id).app_name);
+ } else {
+ dbAppIds.push(r.client_app_id);
+ }
+ }
+ if (dbAppIds.length > 0) {
+ const appRows = await db
+ .selectFrom("apps")
+ .where("app_id", "in", dbAppIds)
+ .select(["app_id", "app_name"])
+ .execute();
+ for (const row of appRows) {
+ nameById.set(row.app_id, row.app_name);
+ }
+ }
+
+ return ranked.map((r) => ({
+ client_app_id: r.client_app_id,
+ app_name: nameById.get(r.client_app_id) ?? r.client_app_id,
+ access_token_count: r.access,
+ refresh_token_count: r.refresh,
+ }));
+}
+
+export default listTopMostPopularAppsSince;
diff --git a/auth-server/src/lib/auth-db/issued-tokens/count-tokens-issued-since-grouped-by-column.ts b/auth-server/src/lib/auth-db/issued-tokens/count-tokens-issued-since-grouped-by-column.ts
new file mode 100644
index 00000000..71b27316
--- /dev/null
+++ b/auth-server/src/lib/auth-db/issued-tokens/count-tokens-issued-since-grouped-by-column.ts
@@ -0,0 +1,60 @@
+import "server-only";
+import type { Kysely, Transaction } from "@schemavaults/dbh";
+import type { AuthDatabase } from "@/lib/auth-db/auth-database-types";
+import type { IssuedTokenCounts } from "./count-tokens-issued-since-grouped-by-uid";
+
+/**
+ * Columns on `issued_tokens` that can be used to bucket token-issuance counts.
+ * `client_app_id` powers the "most popular applications" report section, while
+ * `audience` powers the "most popular APIs" section.
+ */
+export type GroupableTokenColumn = "client_app_id" | "audience";
+
+function toNumber(raw: string | number | bigint): number {
+ if (typeof raw === "number") return raw;
+ if (typeof raw === "bigint") return Number(raw);
+ const parsed = Number.parseInt(raw, 10);
+ if (Number.isNaN(parsed)) {
+ throw new Error(`Failed to parse token count as an integer: "${raw}"`);
+ }
+ return parsed;
+}
+
+/**
+ * Count access/refresh tokens issued since `since_ms`, grouped by an arbitrary
+ * `issued_tokens` column (e.g. `client_app_id` or `audience`).
+ *
+ * @see countTokensIssuedSinceGroupedByUid The per-user variant used for the
+ * "top most-active users" section.
+ */
+export async function countTokensIssuedSinceGroupedByColumn(
+ db: Kysely | Transaction,
+ since_ms: number,
+ column: GroupableTokenColumn,
+): Promise |