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
274 lines
9.4 KiB
TypeScript
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;
|