Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

274 lines
9.4 KiB
TypeScript

/**
* GET /api/analytics/export
* Exports analytics data in various formats (CSV, JSON, Excel).
* Supports time series, user, provider, and model breakdown exports.
*
* NOTE: Excel generation depends on `exceljs`, which uses Node `Buffer`.
* That import is fine on Workers if exceljs's bundle uses the polyfilled
* Buffer; verify after wrangler dev. CSV / JSON paths are pure JS.
*/
import { Hono } from "hono";
import { failureResponse } from "@/lib/api/cloud-worker-errors";
import { requireUserOrApiKeyWithOrg } from "@/lib/auth/workers-hono-auth";
import {
createBinaryDownloadResponse,
createDownloadResponse,
type ExportColumn,
type ExportOptions,
formatCurrency,
formatDate,
formatNumber,
formatPercentage,
generateCSV,
generateExcel,
generateJSON,
} from "@/lib/export/analytics";
import {
RateLimitPresets,
rateLimit,
} from "@/lib/middleware/rate-limit-hono-cloudflare";
import {
getModelBreakdown,
getProviderBreakdown,
getUsageByUser,
getUsageTimeSeries,
type TimeGranularity,
validateGranularity,
} from "@/lib/services/analytics";
import { logger } from "@/lib/utils/logger";
import type { AppEnv } from "@/types/cloud-worker-env";
const EXPORT_LIMITS = {
MAX_TIME_RANGE_DAYS: 365,
MAX_ROWS: 100_000,
MAX_ROWS_WARNING: 50_000,
} as const;
const SUPPORTED_FORMATS = new Set(["csv", "json", "excel", "xlsx"]);
const app = new Hono<AppEnv>();
app.use("*", rateLimit(RateLimitPresets.STANDARD));
app.get("/", async (c) => {
try {
const user = await requireUserOrApiKeyWithOrg(c);
const format = c.req.query("format") || "csv";
if (!SUPPORTED_FORMATS.has(format)) {
return c.json(
{
error: `Unsupported export format: ${format}. Supported formats: csv, json, excel, xlsx`,
},
400,
);
}
const startDateRaw = c.req.query("startDate");
const endDateRaw = c.req.query("endDate");
const startDate = startDateRaw
? new Date(startDateRaw)
: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const endDate = endDateRaw ? new Date(endDateRaw) : new Date();
const timeRangeDays =
(endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24);
if (timeRangeDays > EXPORT_LIMITS.MAX_TIME_RANGE_DAYS) {
return c.json(
{
error: `Time range too large. Maximum: ${EXPORT_LIMITS.MAX_TIME_RANGE_DAYS} days, requested: ${Math.ceil(timeRangeDays)} days`,
maxDays: EXPORT_LIMITS.MAX_TIME_RANGE_DAYS,
},
400,
);
}
if (startDate >= endDate) {
return c.json({ error: "startDate must be before endDate" }, 400);
}
const granularityParam = c.req.query("granularity") || "day";
if (!validateGranularity(granularityParam)) {
return c.json(
{
error: `Invalid granularity: ${granularityParam}. Must be one of: hour, day, week, month`,
},
400,
);
}
const granularity = granularityParam as TimeGranularity;
const dataType = c.req.query("type") || "timeseries";
const includeMetadata = c.req.query("includeMetadata") === "true";
const exportOptions: ExportOptions = {
includeTimestamp: true,
includeMetadata,
};
let data: Array<Record<string, unknown>>;
let columns: ExportColumn[];
let filename: string;
if (dataType === "users") {
const userBreakdown = await getUsageByUser(user.organization_id, {
startDate,
endDate,
limit: EXPORT_LIMITS.MAX_ROWS,
});
data = userBreakdown.map((u) => ({
email: u.userEmail,
name: u.userName || "Unknown",
requests: u.totalRequests,
cost: u.totalCost,
inputTokens: u.inputTokens,
outputTokens: u.outputTokens,
lastActive: u.lastActive?.toISOString() || "",
}));
columns = [
{ key: "email", label: "Email" },
{ key: "name", label: "Name" },
{ key: "requests", label: "Total Requests", format: formatNumber },
{ key: "cost", label: "Total Cost (Credits)", format: formatCurrency },
{ key: "inputTokens", label: "Input Tokens", format: formatNumber },
{ key: "outputTokens", label: "Output Tokens", format: formatNumber },
{ key: "lastActive", label: "Last Active", format: formatDate },
];
filename = `user-analytics-${startDate.toISOString().split("T")[0]}-to-${endDate.toISOString().split("T")[0]}`;
} else if (dataType === "providers") {
const providerBreakdown = await getProviderBreakdown(
user.organization_id,
{
startDate,
endDate,
},
);
data = providerBreakdown.map((p) => ({
provider: p.provider,
requests: p.totalRequests,
cost: p.totalCost,
tokens: p.totalTokens,
successRate: p.successRate,
percentage: p.percentage,
}));
columns = [
{ key: "provider", label: "Provider" },
{ key: "requests", label: "Total Requests", format: formatNumber },
{ key: "cost", label: "Total Cost (Credits)", format: formatCurrency },
{ key: "tokens", label: "Total Tokens", format: formatNumber },
{ key: "successRate", label: "Success Rate", format: formatPercentage },
{
key: "percentage",
label: "Usage %",
format: (v) => `${Number(v).toFixed(1)}%`,
},
];
filename = `provider-analytics-${startDate.toISOString().split("T")[0]}-to-${endDate.toISOString().split("T")[0]}`;
} else if (dataType === "models") {
const modelBreakdown = await getModelBreakdown(user.organization_id, {
startDate,
endDate,
limit: EXPORT_LIMITS.MAX_ROWS,
});
data = modelBreakdown.map((m) => ({
model: m.model,
provider: m.provider,
requests: m.totalRequests,
cost: m.totalCost,
tokens: m.totalTokens,
avgCostPerToken: m.avgCostPerToken,
successRate: m.successRate,
}));
columns = [
{ key: "model", label: "Model" },
{ key: "provider", label: "Provider" },
{ key: "requests", label: "Total Requests", format: formatNumber },
{ key: "cost", label: "Total Cost (Credits)", format: formatCurrency },
{ key: "tokens", label: "Total Tokens", format: formatNumber },
{
key: "avgCostPerToken",
label: "Avg Cost/Token",
format: (v) => Number(v).toFixed(6),
},
{ key: "successRate", label: "Success Rate", format: formatPercentage },
];
filename = `model-analytics-${startDate.toISOString().split("T")[0]}-to-${endDate.toISOString().split("T")[0]}`;
} else {
const timeSeriesData = await getUsageTimeSeries(user.organization_id, {
startDate,
endDate,
granularity,
});
data = timeSeriesData.map((point) => ({
timestamp: point.timestamp.toISOString(),
requests: point.totalRequests,
cost: point.totalCost,
inputTokens: point.inputTokens,
outputTokens: point.outputTokens,
successRate: point.successRate,
}));
columns = [
{ key: "timestamp", label: "Timestamp", format: formatDate },
{ key: "requests", label: "Total Requests", format: formatNumber },
{ key: "cost", label: "Total Cost (Credits)", format: formatCurrency },
{ key: "inputTokens", label: "Input Tokens", format: formatNumber },
{ key: "outputTokens", label: "Output Tokens", format: formatNumber },
{ key: "successRate", label: "Success Rate", format: formatPercentage },
];
filename = `usage-analytics-${granularity}-${startDate.toISOString().split("T")[0]}-to-${endDate.toISOString().split("T")[0]}`;
}
if (data.length > EXPORT_LIMITS.MAX_ROWS) {
return c.json(
{
error: `Result set too large. Maximum: ${EXPORT_LIMITS.MAX_ROWS} rows, found: ${data.length} rows. Please narrow your date range or filters.`,
limit: EXPORT_LIMITS.MAX_ROWS,
actualRows: data.length,
suggestion: "Use smaller date range or add filters",
},
413,
);
}
const responseHeaders: Record<string, string> = {};
if (data.length > EXPORT_LIMITS.MAX_ROWS_WARNING) {
responseHeaders["X-Large-Export-Warning"] = "true";
responseHeaders["X-Row-Count"] = data.length.toString();
}
if (format === "json") {
const response = createDownloadResponse(
generateJSON(data, exportOptions),
`${filename}.json`,
"application/json",
);
for (const [k, v] of Object.entries(responseHeaders))
response.headers.set(k, v);
return response;
}
if (format === "excel" || format === "xlsx") {
const excelBuffer = await generateExcel(data, columns, exportOptions);
const response = createBinaryDownloadResponse(
excelBuffer,
`${filename}.xlsx`,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
for (const [k, v] of Object.entries(responseHeaders))
response.headers.set(k, v);
return response;
}
const response = createDownloadResponse(
generateCSV(data, columns, exportOptions),
`${filename}.csv`,
"text/csv",
);
for (const [k, v] of Object.entries(responseHeaders))
response.headers.set(k, v);
return response;
} catch (error) {
logger.error("[Analytics Export] Error:", error);
return failureResponse(c, error);
}
});
export default app;