Move queryAnalytics to server app (#1687)

Moves `queryAnalytics` from the admin app to the server app so backend
code can call it without needing an over-privileged admin key. All
existing dashboard pages that ran queries internally are updated to use
`useServerApp()` instead of `useAdminApp()`.

## What changed

- **Backend**: New `POST /api/v1/analytics/query` and `POST
/api/v1/analytics/query/timing` endpoints on the **server** app
(previously admin-only)
- **Dashboard**: `queryAnalytics` calls in Queries, Query Analytics,
Session Replays, and Sign-up Rules pages switched from `adminApp` →
`serverApp`
- **Docs**: Added SDK + REST API usage example under the Queries section
of the Analytics guide
- **REST API reference**: New `Run analytics query` and `Get analytics
query timing` endpoints visible under Server API > Analytics

## Docs — Analytics › Queries section

New paragraph + code snippet showing
`hexclaveServerApp.queryAnalytics()` from backend code.

| | Light | Dark |
|---|---|---|
| **Before** |
![](https://gist.githubusercontent.com/aadesh18/e7950872b14557cfac823a99cb90ea9f/raw/analytics-before-light-queries.png)
|
![](https://gist.githubusercontent.com/aadesh18/e7950872b14557cfac823a99cb90ea9f/raw/analytics-before-dark-queries.png)
|
| **After** |
![](https://gist.githubusercontent.com/aadesh18/e7950872b14557cfac823a99cb90ea9f/raw/analytics-after-light-queries.png)
|
![](https://gist.githubusercontent.com/aadesh18/e7950872b14557cfac823a99cb90ea9f/raw/analytics-after-dark-queries.png)
|

<details>
<summary>Full-page overview (both themes)</summary>

| Light | Dark |
|---|---|
|
![](https://gist.githubusercontent.com/aadesh18/e7950872b14557cfac823a99cb90ea9f/raw/analytics-after-light.png)
|
![](https://gist.githubusercontent.com/aadesh18/e7950872b14557cfac823a99cb90ea9f/raw/analytics-after-dark.png)
|

</details>

## REST API reference — new Server API endpoints

`POST /analytics/query` and `POST /analytics/query/timing` now appear
under **Server API > Analytics** in the Mintlify reference.

| Light | Dark |
|---|---|
|
![](https://gist.githubusercontent.com/aadesh18/e7950872b14557cfac823a99cb90ea9f/raw/mintlify-run-analytics-query-light.png)
|
![](https://gist.githubusercontent.com/aadesh18/e7950872b14557cfac823a99cb90ea9f/raw/mintlify-run-analytics-query-dark.png)
|


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added public server-access analytics query support, including a query
timing endpoint for performance details.
  * Enriched API documentation for analytics queries and results.
* **Bug Fixes**
* Switched analytics querying across the dashboard and E2E tests to use
the correct public route and server access flow.
* Increased the long-request warning threshold for the analytics query
endpoint.
* **Documentation**
* Updated OpenAPI specs and the analytics guide with REST endpoint
details and examples.
* **Breaking Changes**
* Removed analytics query support from the admin client-side flow; use
the server-access method instead.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
aadesh18 2026-07-02 09:42:10 -07:00 committed by GitHub
parent c5c853ccff
commit d5d2f27a96
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 512 additions and 114 deletions

View File

@ -1,37 +1,44 @@
import { getHexclaveServerApp } from "@/hexclave";
import { getClickhouseExternalClient } from "@/lib/clickhouse";
import { getSafeClickhouseErrorMessage } from "@/lib/clickhouse-errors";
import { arePlanLimitsEnforced, getBillingTeamId } from "@/lib/plan-entitlements";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { getHexclaveServerApp } from "@/hexclave";
import { KnownErrors } from "@hexclave/shared";
import { ITEM_IDS, PLAN_LIMITS } from "@hexclave/shared/dist/plans";
import { adaptSchema, adminAuthTypeSchema, jsonSchema, yupBoolean, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@hexclave/shared/dist/schema-fields";
import { adaptSchema, jsonSchema, serverOrHigherAuthTypeSchema, yupArray, yupBoolean, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@hexclave/shared/dist/schema-fields";
import type { Json } from "@hexclave/shared/dist/utils/json";
import { Result } from "@hexclave/shared/dist/utils/results";
import { randomUUID } from "crypto";
const MAX_QUERY_TIMEOUT_MS = Math.max(...Object.values(PLAN_LIMITS).map(p => p.analyticsTimeoutSeconds)) * 1000;
const DEFAULT_QUERY_TIMEOUT_MS = 10_000;
const MAX_RESULT_ROWS = 10_000;
const MAX_RESULT_BYTES = 10 * 1024 * 1024;
export const POST = createSmartRouteHandler({
metadata: { hidden: true },
metadata: {
summary: "Run analytics query",
description: "Runs a read-only ClickHouse SQL query against the current project's analytics dataset.",
tags: ["Analytics"],
},
request: yupObject({
auth: yupObject({
type: adminAuthTypeSchema,
type: serverOrHigherAuthTypeSchema,
tenancy: adaptSchema,
}).defined(),
body: yupObject({
include_all_branches: yupBoolean().default(false),
query: yupString().defined().nonEmpty(),
params: yupRecord(yupString().defined(), yupMixed().defined()).default({}),
timeout_ms: yupNumber().integer().min(1_000).max(MAX_QUERY_TIMEOUT_MS).default(DEFAULT_QUERY_TIMEOUT_MS),
include_all_branches: yupBoolean().default(false).meta({ openapiField: { description: "Reserved for future branch-wide analytics queries. Must be false.", exampleValue: false } }),
query: yupString().defined().nonEmpty().meta({ openapiField: { description: "A read-only ClickHouse SQL query.", exampleValue: "SELECT count() AS event_count FROM events" } }),
params: yupRecord(yupString().defined(), yupMixed().defined()).default({}).meta({ openapiField: { description: "ClickHouse query parameters referenced by the query.", exampleValue: { event_type: "$page-view" } } }),
timeout_ms: yupNumber().integer().min(1_000).max(MAX_QUERY_TIMEOUT_MS).default(DEFAULT_QUERY_TIMEOUT_MS).meta({ openapiField: { description: "Maximum query execution time in milliseconds. The effective timeout is also capped by the project's plan.", exampleValue: DEFAULT_QUERY_TIMEOUT_MS } }),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
result: jsonSchema.defined(),
query_id: yupString().defined(),
result: yupArray(jsonSchema.defined()).defined().meta({ openapiField: { description: "Query result rows as plain JSON objects.", exampleValue: [{ event_count: 42 }] } }),
query_id: yupString().defined().meta({ openapiField: { description: "The ClickHouse query ID. Use it to fetch query timing stats.", exampleValue: "00000000-0000-0000-0000-000000000000:main:00000000-0000-0000-0000-000000000001" } }),
}).defined(),
}),
async handler({ body, auth }) {
@ -44,6 +51,7 @@ export const POST = createSmartRouteHandler({
// TODO: when multi-branch support lands, make include_all_branches=true query
// across all branches in the project instead of just auth.tenancy.branchId.
let effectiveTimeoutMs = body.timeout_ms;
const billingTeamId = getBillingTeamId(auth.tenancy.project);
if (billingTeamId != null && arePlanLimitsEnforced()) {
@ -84,7 +92,7 @@ export const POST = createSmartRouteHandler({
throw new KnownErrors.AnalyticsQueryError(message);
}
const rows = await resultSet.data.json<Record<string, unknown>[]>();
const rows = await resultSet.data.json<Record<string, Json>>();
return {
statusCode: 200,
bodyType: "json",
@ -96,5 +104,3 @@ export const POST = createSmartRouteHandler({
},
});
const MAX_RESULT_ROWS = 10_000;
const MAX_RESULT_BYTES = 10 * 1024 * 1024;

View File

@ -4,14 +4,18 @@ import { KnownErrors } from "@hexclave/shared";
import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
export const POST = createSmartRouteHandler({
metadata: { hidden: true },
metadata: {
summary: "Get analytics query timing",
description: "Returns CPU and wall-clock timing stats for a previously run analytics query.",
tags: ["Analytics"],
},
request: yupObject({
auth: yupObject({
type: serverOrHigherAuthTypeSchema,
tenancy: adaptSchema,
}).defined(),
body: yupObject({
query_id: yupString().defined().nonEmpty(),
query_id: yupString().defined().nonEmpty().meta({ openapiField: { description: "The query_id returned from POST /analytics/query.", exampleValue: "00000000-0000-0000-0000-000000000000:main:00000000-0000-0000-0000-000000000001" } }),
}).defined(),
}),
response: yupObject({
@ -19,8 +23,8 @@ export const POST = createSmartRouteHandler({
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
stats: yupObject({
cpu_time: yupNumber().defined(),
wall_clock_time: yupNumber().defined(),
cpu_time: yupNumber().defined().meta({ openapiField: { description: "ClickHouse CPU time in milliseconds.", exampleValue: 12 } }),
wall_clock_time: yupNumber().defined().meta({ openapiField: { description: "ClickHouse wall-clock time in milliseconds.", exampleValue: 18 } }),
}).defined(),
}).defined(),
}),

View File

@ -107,8 +107,8 @@ export function handleApiRequest(handler: (req: NextRequest, options: any, reque
const allowedLongRequestPaths = [
"/api/latest/internal/email-queue-step",
"/api/latest/analytics/clickmap",
"/api/latest/analytics/query",
"/api/latest/internal/analytics/clickmap",
"/api/latest/internal/analytics/query",
"/api/latest/ai/query/stream",
"/api/latest/ai/query/generate",
"/health/email",

View File

@ -33,7 +33,7 @@ import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises
import { useCallback, useMemo, useState } from "react";
import { AppEnabledGuard } from "../../app-enabled-guard";
import { PageLayout } from "../../page-layout";
import { useAdminApp } from "../../use-admin-app";
import { useAdminApp, useServerApp } from "../../use-admin-app";
import {
AnalyticsEventLimitBanner,
ErrorDisplay,
@ -324,6 +324,7 @@ function DeleteConfirmDialog({
// Main content component
function QueriesContent() {
const adminApp = useAdminApp();
const serverApp = useServerApp();
const project = adminApp.useProject();
const config = project.useConfig();
const updateConfig = useUpdateConfig();
@ -377,7 +378,7 @@ function QueriesContent() {
setHasQueried(true);
try {
const response = await adminApp.queryAnalytics({
const response = await serverApp.queryAnalytics({
query: trimmedQuery,
include_all_branches: false,
timeout_ms: 30000,
@ -395,7 +396,7 @@ function QueriesContent() {
} finally {
setLoading(false);
}
}, [adminApp, sqlQuery]);
}, [serverApp, sqlQuery]);
const handleSelectQuery = (folderId: string, query: { id: string, displayName: string, sqlQuery: string, description?: string }) => {
setSelectedFolderId(folderId);

View File

@ -31,7 +31,7 @@ import {
useState,
type ReactNode,
} from "react";
import { useAdminApp } from "../../use-admin-app";
import { useServerApp } from "../../use-admin-app";
import {
isJsonValue,
JsonValue,
@ -332,14 +332,14 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
},
ref,
) {
const adminApp = useAdminApp();
const serverApp = useServerApp();
const [discoveredColumns, setDiscoveredColumns] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [selectedRow, setSelectedRow] = useState<RowData | null>(null);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
// Ref mirror so the async generator (memoised against adminApp) can
// Ref mirror so the async generator (memoised against serverApp) can
// read the latest column list without being re-created every time
// the schema updates.
const discoveredColumnsRef = useRef<string[]>([]);
@ -462,7 +462,7 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
offset,
});
const response = await adminApp.queryAnalytics({
const response = await serverApp.queryAnalytics({
query: finalQuery,
include_all_branches: false,
timeout_ms: 30000,
@ -495,7 +495,7 @@ export const QueryDataGrid = forwardRef<QueryDataGridHandle, QueryDataGridProps>
yield { rows: [], hasMore: false };
}
};
}, [adminApp]);
}, [serverApp]);
const getRowId = useCallback((row: RowData): string => {
if (typeof row[INTERNAL_ROW_ID_KEY] === "string") return row[INTERNAL_ROW_ID_KEY];

View File

@ -6,7 +6,7 @@ import React, { useEffect, useMemo, useRef } from "react";
import { getShortcutModifierKeyLabel } from "@/lib/keyboard-shortcuts";
import { Alert, Button, Textarea, Typography } from "@/components/ui";
import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
import { useServerApp } from "../use-admin-app";
import { clickhouseKeywords, clickhouseTables, conf, language } from "./monaco-clickhouse";
const CLICKHOUSE_LANGUAGE_ID = "clickhouse-sql";
@ -21,7 +21,7 @@ type CompletionItem = Parameters<Monaco["languages"]["registerCompletionItemProv
: never;
export default function PageClient() {
const adminApp = useAdminApp();
const serverApp = useServerApp();
const modifierKeyLabel = getShortcutModifierKeyLabel();
const [query, setQuery] = React.useState("SELECT 1 AS value;");
const [resultText, setResultText] = React.useState("");
@ -53,7 +53,7 @@ export default function PageClient() {
setLoading(true);
setError(null);
try {
const response = await adminApp.queryAnalytics({
const response = await serverApp.queryAnalytics({
query: currentQuery,
include_all_branches: false,
});

View File

@ -25,7 +25,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { AppEnabledGuard } from "../app-enabled-guard";
import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
import { useAdminApp, useServerApp } from "../use-admin-app";
import { SessionReplayLimitBanner } from "../analytics/shared";
import {
ALLOWED_PLAYER_SPEEDS,
@ -484,6 +484,7 @@ type PageClientProps = {
export default function PageClient({ initialReplayId, lockedUserId }: PageClientProps) {
const adminApp = useAdminApp() as AdminAppWithSessionReplays;
const serverApp = useServerApp();
const isStandaloneReplayPage = initialReplayId != null;
const isEmbedded = lockedUserId != null;
const baseFilters = useMemo<ReplayFilters>(
@ -605,7 +606,7 @@ export default function PageClient({ initialReplayId, lockedUserId }: PageClient
if (recordings.length === 0) return;
const ids = recordings.map(r => r.id);
runAsynchronously(async () => {
const res = await adminApp.queryAnalytics({
const res = await serverApp.queryAnalytics({
query: `SELECT session_replay_id, count() as cnt
FROM default.events
WHERE event_type = '$click'
@ -621,7 +622,7 @@ export default function PageClient({ initialReplayId, lockedUserId }: PageClient
}
setClickCountsByReplayId(map);
}, { noErrorLogging: true });
}, [isStandaloneReplayPage, recordings, adminApp]);
}, [isStandaloneReplayPage, recordings, serverApp]);
useEffect(() => {
if (initialReplayId == null) return;
@ -1228,7 +1229,7 @@ export default function PageClient({ initialReplayId, lockedUserId }: PageClient
let cancelled = false;
setTimelineEvents([]);
runAsynchronously(async () => {
const res = await adminApp.queryAnalytics({
const res = await serverApp.queryAnalytics({
query: `SELECT event_type,
toUnixTimestamp64Milli(event_at) as event_at_ms,
data
@ -1253,7 +1254,7 @@ export default function PageClient({ initialReplayId, lockedUserId }: PageClient
return () => {
cancelled = true;
};
}, [selectedRecordingId, adminApp]);
}, [selectedRecordingId, serverApp]);
useEffect(() => {
return () => {

View File

@ -83,7 +83,7 @@ import React, { useMemo, useRef, useState } from "react";
import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts";
import { AppEnabledGuard } from "../app-enabled-guard";
import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
import { useAdminApp, useServerApp } from "../use-admin-app";
import { validateRiskScore } from "@/lib/risk-score-utils";
import { parseClickHouseDate } from "../analytics/shared";
@ -756,7 +756,7 @@ function RuleTriggerHistoryDialog({
timespanHours: number,
isSparklineLoading: boolean,
}) {
const hexclaveAdminApp = useAdminApp();
const serverApp = useServerApp();
const [open, setOpen] = useState(false);
const [triggers, setTriggers] = useState<RuleTriggerListItem[]>([]);
const [hasMore, setHasMore] = useState(true);
@ -783,7 +783,7 @@ function RuleTriggerHistoryDialog({
}
try {
const response = await hexclaveAdminApp.queryAnalytics({
const response = await serverApp.queryAnalytics({
query: RULE_TRIGGER_EVENTS_QUERY,
params: {
rule_id: ruleId,

View File

@ -22,7 +22,7 @@ import {
XAxis,
YAxis,
} from "recharts";
import { useAdminApp } from "../../use-admin-app";
import { useAdminApp, useServerApp } from "../../use-admin-app";
import { UserPageMetricCard } from "../../users/[userId]/user-page-metric-card";
import { UserPageTableSection } from "../../users/[userId]/user-page-table-section";
@ -236,6 +236,7 @@ function densifyDau(dau: DauRow[], range: { startUtc: Date, endUtcInclusive: Dat
export function TeamAnalyticsSection({ team }: { team: ServerTeam }) {
const hexclaveAdminApp = useAdminApp();
const serverApp = useServerApp();
const members = team.useUsers();
const memberIds = useMemo(() => members.map((m) => m.id), [members]);
const memberIdsKey = useMemo(() => memberIds.join(","), [memberIds]);
@ -279,7 +280,7 @@ export function TeamAnalyticsSection({ team }: { team: ServerTeam }) {
};
const runQuery = (query: string, params: Record<string, unknown>) =>
hexclaveAdminApp.queryAnalytics({ query, params, timeout_ms: 30_000, include_all_branches: false });
serverApp.queryAnalytics({ query, params, timeout_ms: 30_000, include_all_branches: false });
const emptySummary: SummaryRow = {
total_events: 0,
@ -348,7 +349,7 @@ export function TeamAnalyticsSection({ team }: { team: ServerTeam }) {
token.cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- dependency on serialized member IDs, not referential equality
}, [hexclaveAdminApp, team.id, memberIdsKey]);
}, [hexclaveAdminApp, serverApp, team.id, memberIdsKey]);
return (
<div className="flex flex-col gap-4">

View File

@ -1,7 +1,7 @@
"use client";
import { useDashboardInternalUser } from "@/lib/dashboard-user";
import { StackAdminApp } from "@hexclave/next";
import type { StackAdminApp, StackServerApp } from "@hexclave/next";
import { HexclaveAssertionError, throwErr } from "@hexclave/shared/dist/utils/errors";
import { notFound, usePathname } from "next/navigation";
import React from "react";
@ -27,6 +27,10 @@ export function useAdminAppIfExists() {
return hexclaveAdminApp;
}
export function useServerAppIfExists(): StackServerApp<false> | null {
return useAdminAppIfExists();
}
export function useAdminApp(projectId?: string) {
const user = useDashboardInternalUser();
const projects = user.useOwnedProjects();
@ -44,6 +48,10 @@ export function useAdminApp(projectId?: string) {
}
}
export function useServerApp(projectId?: string): StackServerApp<false> {
return useAdminApp(projectId);
}
export function useProjectId() {
const pathname = usePathname();
if (!pathname.startsWith("/projects/")) {

View File

@ -24,7 +24,7 @@ import {
XAxis,
YAxis,
} from "recharts";
import { useAdminApp } from "../../use-admin-app";
import { useServerApp } from "../../use-admin-app";
import { UserPageMetricCard } from "./user-page-metric-card";
import { UserPageTableSection } from "./user-page-table-section";
@ -440,7 +440,7 @@ function parseDayFilterRange(dayFilter: string): { since: Date, until: Date } |
}
export function UserAnalyticsSection({ user, dayFilter, onClearDayFilter }: UserAnalyticsSectionProps) {
const hexclaveAdminApp = useAdminApp();
const serverApp = useServerApp();
const [state, setState] = useState<LoadState>({ status: "loading" });
const [recent, setRecent] = useState<RecentEventsState>({ rows: [], hasMore: false, isLoadingMore: false });
const recentContextRef = useRef<RecentFetchContext | null>(null);
@ -503,7 +503,7 @@ export function UserAnalyticsSection({ user, dayFilter, onClearDayFilter }: User
};
const runQuery = (query: string, params: Record<string, unknown>) =>
hexclaveAdminApp.queryAnalytics({ query, params, timeout_ms: 30_000, include_all_branches: false });
serverApp.queryAnalytics({ query, params, timeout_ms: 30_000, include_all_branches: false });
runAsynchronously(async () => {
const [summaryRes, dailyRes, topPagesRes, topReferrersRes, recentRes] = await Promise.all([
@ -548,7 +548,7 @@ export function UserAnalyticsSection({ user, dayFilter, onClearDayFilter }: User
return () => {
token.cancelled = true;
};
}, [hexclaveAdminApp, user.id, filterRange]);
}, [serverApp, user.id, filterRange]);
const onLoadMoreRecent = useCallback(() => {
const current = recent;
@ -558,7 +558,7 @@ export function UserAnalyticsSection({ user, dayFilter, onClearDayFilter }: User
const offset = current.rows.length;
setRecent((p) => ({ ...p, isLoadingMore: true }));
runAsynchronously(async () => {
const res = await hexclaveAdminApp.queryAnalytics({
const res = await serverApp.queryAnalytics({
query: RECENT_EVENTS_QUERY,
params: { ...ctx.params, limit: RECENT_EVENTS_PAGE_SIZE, offset },
timeout_ms: 30_000,
@ -579,7 +579,7 @@ export function UserAnalyticsSection({ user, dayFilter, onClearDayFilter }: User
setRecent((p) => ({ ...p, isLoadingMore: false, hasMore: false }));
},
});
}, [hexclaveAdminApp, recent]);
}, [recent, serverApp]);
return (
<div className="flex flex-col gap-4">

View File

@ -10,7 +10,7 @@ import {
RowDetailDialog,
VirtualizedFlatTable
} from "@/app/(main)/(protected)/projects/[projectId]/analytics/shared";
import { useAdminAppIfExists } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
import { useAdminAppIfExists, useServerAppIfExists } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
import { useRouter } from "@/components/router";
import { Button } from "@/components/ui";
import {
@ -380,6 +380,7 @@ const RunQueryPreviewInner = memo(function RunQueryPreviewInner({
query,
}: CmdKPreviewProps) {
const adminApp = useAdminAppIfExists();
const serverApp = useServerAppIfExists();
const [columns, setColumns] = useState<string[]>([]);
const [rows, setRows] = useState<RowData[]>([]);
const [error, setError] = useState<unknown>(null);
@ -392,7 +393,7 @@ const RunQueryPreviewInner = memo(function RunQueryPreviewInner({
const trimmedQuery = query.trim();
const runQuery = useCallback(async () => {
if (!adminApp) {
if (!serverApp) {
setError(new Error("Not connected to a project"));
return;
}
@ -406,7 +407,7 @@ const RunQueryPreviewInner = memo(function RunQueryPreviewInner({
setHasQueried(true);
try {
const response = await adminApp.queryAnalytics({
const response = await serverApp.queryAnalytics({
query: trimmedQuery,
include_all_branches: false,
timeout_ms: 30000,
@ -424,7 +425,7 @@ const RunQueryPreviewInner = memo(function RunQueryPreviewInner({
} finally {
setLoading(false);
}
}, [adminApp, trimmedQuery]);
}, [serverApp, trimmedQuery]);
// Run query on mount with debounce
useDebouncedAction({

View File

@ -558,9 +558,9 @@ it("inserted events are queryable via analytics query endpoint", async ({ expect
let queryRes;
for (let attempt = 0; attempt < 15; attempt++) {
await wait(500);
queryRes = await niceBackendFetch("/api/v1/internal/analytics/query", {
queryRes = await niceBackendFetch("/api/v1/analytics/query", {
method: "POST",
accessType: "admin",
accessType: "server",
body: {
query: "SELECT event_type, session_replay_segment_id FROM events WHERE session_replay_segment_id = {segId:String} ORDER BY event_at",
params: { segId: sessionReplaySegmentId },

View File

@ -17,9 +17,9 @@ const stripQueryId = <T extends { status: number, body?: Record<string, unknown>
const queryEvents = async (params: {
userId?: string,
eventType?: string,
}) => await niceBackendFetch("/api/v1/internal/analytics/query", {
}) => await niceBackendFetch("/api/v1/analytics/query", {
method: "POST",
accessType: "admin",
accessType: "server",
body: {
query: `
SELECT event_type, project_id, branch_id, user_id, team_id
@ -40,9 +40,9 @@ const queryEvents = async (params: {
const queryEventDataJson = async (params: {
userId?: string,
eventType?: string,
}) => await niceBackendFetch("/api/v1/internal/analytics/query", {
}) => await niceBackendFetch("/api/v1/analytics/query", {
method: "POST",
accessType: "admin",
accessType: "server",
body: {
query: `
SELECT toJSONString(data) AS data_json

View File

@ -9,9 +9,9 @@ import { waitForItemQuantityToReach } from "../../../payment-quota-helpers";
async function runQuery(body: { query: string, params?: Record<string, string>, timeout_ms?: number }) {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
const response = await niceBackendFetch("/api/v1/internal/analytics/query", {
const response = await niceBackendFetch("/api/v1/analytics/query", {
method: "POST",
accessType: "admin",
accessType: "server",
body,
});
@ -36,9 +36,9 @@ async function runQueryWithPlan(planId: PlanId, body: { query: string, params?:
await waitForItemQuantityToReach(ownerTeamId, ITEM_IDS.analyticsTimeoutSeconds, PLAN_LIMITS[planId].analyticsTimeoutSeconds);
}
const response = await niceBackendFetch("/api/v1/internal/analytics/query", {
const response = await niceBackendFetch("/api/v1/analytics/query", {
method: "POST",
accessType: "admin",
accessType: "server",
body,
});
@ -58,7 +58,7 @@ const stripQueryId = <T extends { status: number, body?: Record<string, unknown>
};
async function fetchQueryTiming(queryId: string) {
return await niceBackendFetch("/api/v1/internal/analytics/query/timing", {
return await niceBackendFetch("/api/v1/analytics/query/timing", {
method: "POST",
accessType: "server",
body: {
@ -79,7 +79,7 @@ async function fetchQueryTimingWithRetry(queryId: string, attempts = 5, delayMs
return response;
}
it("can execute a basic query with admin access", async ({ expect }) => {
it("can execute a basic query with server access", async ({ expect }) => {
const response = await runQuery({ query: "SELECT 1 as value" });
expect(stripQueryId(response, expect)).toMatchInlineSnapshot(`
@ -198,12 +198,12 @@ it("rejects timeouts longer than max plan limit", async ({ expect }) => {
"code": "SCHEMA_ERROR",
"details": {
"message": deindent\`
Request validation failed on POST /api/v1/internal/analytics/query:
Request validation failed on POST /api/v1/analytics/query:
- body.timeout_ms must be less than or equal to ${maxSchemaMs}
\`,
},
"error": deindent\`
Request validation failed on POST /api/v1/internal/analytics/query:
Request validation failed on POST /api/v1/analytics/query:
- body.timeout_ms must be less than or equal to ${maxSchemaMs}
\`,
},
@ -225,12 +225,12 @@ it("validates required query field", async ({ expect }) => {
"code": "SCHEMA_ERROR",
"details": {
"message": deindent\`
Request validation failed on POST /api/v1/internal/analytics/query:
Request validation failed on POST /api/v1/analytics/query:
- body.query must be defined
\`,
},
"error": deindent\`
Request validation failed on POST /api/v1/internal/analytics/query:
Request validation failed on POST /api/v1/analytics/query:
- body.query must be defined
\`,
},
@ -1861,9 +1861,9 @@ it("rejects analytics queries when the timeout quota is zero (would otherwise se
// Wait for the bulldozer timefold to materialize the drained quota.
await waitForItemQuantityToReach(ownerTeamId, ITEM_IDS.analyticsTimeoutSeconds, 0);
const response = await niceBackendFetch("/api/v1/internal/analytics/query", {
const response = await niceBackendFetch("/api/v1/analytics/query", {
method: "POST",
accessType: "admin",
accessType: "server",
body: {
query: "SELECT getSetting('max_execution_time') as max_execution_time",
timeout_ms: 5000,

View File

@ -22,9 +22,9 @@ import {
const COMPLEX_SEQUENCE_TIMEOUT = TEST_TIMEOUT * 2 + 30_000;
async function runQueryForCurrentProject(body: { query: string, params?: Record<string, string>, timeout_ms?: number }) {
return await niceBackendFetch("/api/v1/internal/analytics/query", {
return await niceBackendFetch("/api/v1/analytics/query", {
method: "POST",
accessType: "admin",
accessType: "server",
body,
});
}

View File

@ -38,9 +38,9 @@ import {
} from './external-db-sync-utils';
async function runQueryForCurrentProject(body: { query: string, params?: Record<string, string>, timeout_ms?: number }) {
return await niceBackendFetch("/api/v1/internal/analytics/query", {
return await niceBackendFetch("/api/v1/analytics/query", {
method: "POST",
accessType: "admin",
accessType: "server",
body,
});
}

View File

@ -103,9 +103,9 @@ async function waitForAnalyticsRowsForSessionReplaySegment(
expectedCount: number,
): Promise<void> {
for (let i = 0; i < 30; i++) {
const response = await niceBackendFetch("/api/v1/internal/analytics/query", {
const response = await niceBackendFetch("/api/v1/analytics/query", {
method: "POST",
accessType: "admin",
accessType: "server",
body: {
query: `
SELECT count() AS count

View File

@ -194,9 +194,9 @@ describe("with admin access", () => {
let chResult: any;
for (let attempt = 0; attempt < 15; attempt++) {
await wait(500);
chResult = await niceBackendFetch("/api/v1/internal/analytics/query", {
chResult = await niceBackendFetch("/api/v1/analytics/query", {
method: "POST",
accessType: "admin",
accessType: "server",
body: {
query: `
SELECT

View File

@ -18,9 +18,9 @@ type AnalyticsEvent = {
const queryEvents = async (params: {
userId?: string,
eventType?: string,
}) => await niceBackendFetch("/api/v1/internal/analytics/query", {
}) => await niceBackendFetch("/api/v1/analytics/query", {
method: "POST",
accessType: "admin",
accessType: "server",
body: {
query: `
SELECT event_type, project_id, branch_id, user_id, team_id, event_at

View File

@ -189,6 +189,172 @@
}
}
},
"/analytics/query": {
"post": {
"summary": "Run analytics query",
"description": "Runs a read-only ClickHouse SQL query against the current project's analytics dataset.",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"include_all_branches": {
"type": "boolean",
"example": false,
"description": "Reserved for future branch-wide analytics queries. Must be false.",
"default": false
},
"query": {
"type": "string",
"example": "SELECT count() AS event_count FROM events",
"description": "A read-only ClickHouse SQL query."
},
"params": {
"type": "object",
"properties": {},
"required": [],
"example": {
"event_type": "$page-view"
},
"description": "ClickHouse query parameters referenced by the query.",
"default": {}
},
"timeout_ms": {
"type": "integer",
"example": 10000,
"description": "Maximum query execution time in milliseconds. The effective timeout is also capped by the project's plan.",
"default": 10000
}
},
"required": [
"query"
],
"example": {
"include_all_branches": false,
"query": "SELECT count() AS event_count FROM events",
"params": {
"event_type": "$page-view"
},
"timeout_ms": 10000
}
}
}
}
},
"tags": [
"Analytics"
],
"x-full-url": "https://api.hexclave.com/api/v1/analytics/query",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"result": {
"type": "array",
"items": {
"type": "object"
},
"example": [
{
"event_count": 42
}
],
"description": "Query result rows as plain JSON objects."
},
"query_id": {
"type": "string",
"example": "00000000-0000-0000-0000-000000000000:main:00000000-0000-0000-0000-000000000001",
"description": "The ClickHouse query ID. Use it to fetch query timing stats."
}
},
"required": [
"result",
"query_id"
]
}
}
}
}
}
}
},
"/analytics/query/timing": {
"post": {
"summary": "Get analytics query timing",
"description": "Returns CPU and wall-clock timing stats for a previously run analytics query.",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"query_id": {
"type": "string",
"example": "00000000-0000-0000-0000-000000000000:main:00000000-0000-0000-0000-000000000001",
"description": "The query_id returned from POST /analytics/query."
}
},
"required": [
"query_id"
],
"example": {
"query_id": "00000000-0000-0000-0000-000000000000:main:00000000-0000-0000-0000-000000000001"
}
}
}
}
},
"tags": [
"Analytics"
],
"x-full-url": "https://api.hexclave.com/api/v1/analytics/query/timing",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"stats": {
"type": "object",
"properties": {
"cpu_time": {
"type": "number",
"example": 12,
"description": "ClickHouse CPU time in milliseconds."
},
"wall_clock_time": {
"type": "number",
"example": 18,
"description": "ClickHouse wall-clock time in milliseconds."
}
},
"required": [
"cpu_time",
"wall_clock_time"
]
}
},
"required": [
"stats"
]
}
}
}
}
}
}
},
"/auth/anonymous/sign-up": {
"post": {
"summary": "Sign up anonymously",
@ -2974,7 +3140,7 @@
"/emails/send-email": {
"post": {
"summary": "Send email",
"description": "Send an email to a list of users. The content field should contain either {html} for HTML emails, {template_id, variables} for template-based emails, or {draft_id} for a draft email.",
"description": "Send an email to a list of users (user_ids), all users (all_users), or arbitrary email addresses (emails). The content field should contain either {html} for HTML emails, {template_id, variables} for template-based emails, or {draft_id} for a draft email.",
"parameters": [],
"tags": [
"Emails"
@ -2995,11 +3161,12 @@
"properties": {
"user_id": {
"type": "string"
},
"email": {
"type": "string"
}
},
"required": [
"user_id"
]
"required": []
}
}
},

View File

@ -189,6 +189,172 @@
}
}
},
"/analytics/query": {
"post": {
"summary": "Run analytics query",
"description": "Runs a read-only ClickHouse SQL query against the current project's analytics dataset.",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"include_all_branches": {
"type": "boolean",
"example": false,
"description": "Reserved for future branch-wide analytics queries. Must be false.",
"default": false
},
"query": {
"type": "string",
"example": "SELECT count() AS event_count FROM events",
"description": "A read-only ClickHouse SQL query."
},
"params": {
"type": "object",
"properties": {},
"required": [],
"example": {
"event_type": "$page-view"
},
"description": "ClickHouse query parameters referenced by the query.",
"default": {}
},
"timeout_ms": {
"type": "integer",
"example": 10000,
"description": "Maximum query execution time in milliseconds. The effective timeout is also capped by the project's plan.",
"default": 10000
}
},
"required": [
"query"
],
"example": {
"include_all_branches": false,
"query": "SELECT count() AS event_count FROM events",
"params": {
"event_type": "$page-view"
},
"timeout_ms": 10000
}
}
}
}
},
"tags": [
"Analytics"
],
"x-full-url": "https://api.hexclave.com/api/v1/analytics/query",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"result": {
"type": "array",
"items": {
"type": "object"
},
"example": [
{
"event_count": 42
}
],
"description": "Query result rows as plain JSON objects."
},
"query_id": {
"type": "string",
"example": "00000000-0000-0000-0000-000000000000:main:00000000-0000-0000-0000-000000000001",
"description": "The ClickHouse query ID. Use it to fetch query timing stats."
}
},
"required": [
"result",
"query_id"
]
}
}
}
}
}
}
},
"/analytics/query/timing": {
"post": {
"summary": "Get analytics query timing",
"description": "Returns CPU and wall-clock timing stats for a previously run analytics query.",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"query_id": {
"type": "string",
"example": "00000000-0000-0000-0000-000000000000:main:00000000-0000-0000-0000-000000000001",
"description": "The query_id returned from POST /analytics/query."
}
},
"required": [
"query_id"
],
"example": {
"query_id": "00000000-0000-0000-0000-000000000000:main:00000000-0000-0000-0000-000000000001"
}
}
}
}
},
"tags": [
"Analytics"
],
"x-full-url": "https://api.hexclave.com/api/v1/analytics/query/timing",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"stats": {
"type": "object",
"properties": {
"cpu_time": {
"type": "number",
"example": 12,
"description": "ClickHouse CPU time in milliseconds."
},
"wall_clock_time": {
"type": "number",
"example": 18,
"description": "ClickHouse wall-clock time in milliseconds."
}
},
"required": [
"cpu_time",
"wall_clock_time"
]
}
},
"required": [
"stats"
]
}
}
}
}
}
}
},
"/auth/anonymous/sign-up": {
"post": {
"summary": "Sign up anonymously",
@ -2934,7 +3100,7 @@
"/emails/send-email": {
"post": {
"summary": "Send email",
"description": "Send an email to a list of users. The content field should contain either {html} for HTML emails, {template_id, variables} for template-based emails, or {draft_id} for a draft email.",
"description": "Send an email to a list of users (user_ids), all users (all_users), or arbitrary email addresses (emails). The content field should contain either {html} for HTML emails, {template_id, variables} for template-based emails, or {draft_id} for a draft email.",
"parameters": [],
"tags": [
"Emails"
@ -2955,11 +3121,12 @@
"properties": {
"user_id": {
"type": "string"
},
"email": {
"type": "string"
}
},
"required": [
"user_id"
]
"required": []
}
}
},

View File

@ -76,6 +76,16 @@ The **Queries** screen is a ClickHouse SQL workspace for deeper analysis.
- Re-run saved queries with one click
- Edit and overwrite saved query definitions
You can run the same read-only queries from trusted backend code with `hexclaveServerApp.queryAnalytics()`:
```ts
const { result, query_id } = await hexclaveServerApp.queryAnalytics({
query: "SELECT count() AS event_count FROM events",
});
```
The REST API equivalent is `POST /api/v1/analytics/query` with server authentication. Use `POST /api/v1/analytics/query/timing` with the returned `query_id` to fetch query timing stats.
## Session Replays
The **Replays** screen helps you move from "an event happened" to "what the user actually saw."

View File

@ -7,7 +7,6 @@ import type { MoneyAmount } from "../utils/currency-constants";
import { Result } from "../utils/results";
import { urlString } from "../utils/urls";
import type { AnalyticsClickmapDevice, AnalyticsClickmapKind, AnalyticsClickmapResponse, AnalyticsClickmapTokenResponse, MetricsResponse, MetricsUserCounts, UserActivityResponse } from "./admin-metrics";
import type { AnalyticsQueryOptions, AnalyticsQueryResponse } from "./crud/analytics";
import { EmailOutboxCrud } from "./crud/email-outbox";
import { InternalEmailsCrud } from "./crud/emails";
import { InternalApiKeysCrud } from "./crud/internal-api-keys";
@ -1166,25 +1165,6 @@ export class HexclaveAdminInterface extends HexclaveServerInterface {
return await response.json();
}
async queryAnalytics(options: AnalyticsQueryOptions): Promise<AnalyticsQueryResponse> {
const response = await this.sendAdminRequest(
"/internal/analytics/query",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
query: options.query,
params: options.params ?? {},
timeout_ms: options.timeout_ms ?? 1000,
include_all_branches: options.include_all_branches ?? false,
}),
},
null,
);
return await response.json();
}
async listOutboxEmails(options?: { status?: string, simple_status?: string, user_id?: string, limit?: number, cursor?: string }): Promise<EmailOutboxCrud["Server"]["List"]> {
const qs = new URLSearchParams();
if (options?.status) qs.set('status', options.status);

View File

@ -11,6 +11,7 @@ import {
ClientInterfaceOptions,
HexclaveClientInterface
} from "./client-interface";
import type { AnalyticsQueryOptions, AnalyticsQueryResponse } from "./crud/analytics";
import { ConnectedAccountAccessTokenCrud, ConnectedAccountCrud } from "./crud/connected-accounts";
import { ContactChannelsCrud } from "./crud/contact-channels";
import { CurrentUserCrud } from "./crud/current-user";
@ -1028,6 +1029,25 @@ export class HexclaveServerInterface extends HexclaveClientInterface {
return await res.json();
}
async queryAnalytics(options: AnalyticsQueryOptions): Promise<AnalyticsQueryResponse> {
const response = await this.sendServerRequest(
"/analytics/query",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
query: options.query,
params: options.params ?? {},
timeout_ms: options.timeout_ms ?? 1000,
include_all_branches: options.include_all_branches ?? false,
}),
},
null,
);
return await response.json();
}
async updateItemQuantity(
options: (
{ itemId: string, userId: string } |

View File

@ -2,7 +2,6 @@ import { KnownErrors, HexclaveAdminInterface } from "@hexclave/shared";
import { getProductionModeErrors } from "@hexclave/shared/dist/helpers/production-mode";
import { InternalApiKeyCreateCrudResponse } from "@hexclave/shared/dist/interface/admin-interface";
import type { AnalyticsClickmapOptions, AnalyticsClickmapResponse, AnalyticsClickmapTokenResponse, MetricsResponse, MetricsUserCounts, UserActivityResponse } from "@hexclave/shared/dist/interface/admin-metrics";
import { AnalyticsQueryOptions, AnalyticsQueryResponse } from "@hexclave/shared/dist/interface/crud/analytics";
import { EmailTemplateCrud } from "@hexclave/shared/dist/interface/crud/email-templates";
import { InternalApiKeysCrud } from "@hexclave/shared/dist/interface/crud/internal-api-keys";
import { ProjectsCrud } from "@hexclave/shared/dist/interface/crud/projects";
@ -1218,10 +1217,6 @@ export class _HexclaveAdminAppImplIncomplete<HasTokenStore extends boolean, Proj
}
// END_PLATFORM
async queryAnalytics(options: AnalyticsQueryOptions): Promise<AnalyticsQueryResponse> {
return await this._interface.queryAnalytics(options);
}
async getAnalyticsClickmap(options: AnalyticsClickmapOptions): Promise<AnalyticsClickmapResponse> {
return await this._interface.getAnalyticsClickmap({
kind: options.kind,

View File

@ -1,4 +1,5 @@
import { HexclaveServerInterface, KnownErrors } from "@hexclave/shared";
import type { AnalyticsQueryOptions, AnalyticsQueryResponse } from "@hexclave/shared/dist/interface/crud/analytics";
import { ContactChannelsCrud } from "@hexclave/shared/dist/interface/crud/contact-channels";
import { ItemCrud } from "@hexclave/shared/dist/interface/crud/items";
import { NotificationPreferenceCrud } from "@hexclave/shared/dist/interface/crud/notification-preferences";
@ -1650,6 +1651,10 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
await this._emailDeliveryInfoCache.refresh([]);
}
async queryAnalytics(options: AnalyticsQueryOptions): Promise<AnalyticsQueryResponse> {
return await this._interface.queryAnalytics(options);
}
protected override async _refreshSession(session: InternalSession) {
await Promise.all([
super._refreshUser(session),

View File

@ -1,5 +1,4 @@
import type { AnalyticsClickmapOptions, AnalyticsClickmapResponse, AnalyticsClickmapTokenResponse } from "@hexclave/shared/dist/interface/admin-metrics";
import { AnalyticsQueryOptions, AnalyticsQueryResponse } from "@hexclave/shared/dist/interface/crud/analytics";
import type { AdminGetSessionReplayChunkEventsResponse, AdminGetSessionReplayAllEventsResponse } from "@hexclave/shared/dist/interface/crud/session-replays";
import type { Transaction, TransactionType } from "@hexclave/shared/dist/interface/crud/transactions";
import { InternalSession } from "@hexclave/shared/dist/sessions";
@ -158,7 +157,6 @@ export type StackAdminApp<HasTokenStore extends boolean = boolean, ProjectId ext
amountUsd: MoneyAmount,
endAction?: "now" | "at-period-end",
}): Promise<{ refundTransactionId: string }>,
queryAnalytics(options: AnalyticsQueryOptions): Promise<AnalyticsQueryResponse>,
getAnalyticsClickmap(options: AnalyticsClickmapOptions): Promise<AnalyticsClickmapResponse>,
createAnalyticsClickmapToken(options: { origin: string }): Promise<AnalyticsClickmapTokenResponse>,

View File

@ -1,6 +1,7 @@
import { KnownErrors } from "@hexclave/shared";
import { Result } from "@hexclave/shared/dist/utils/results";
import type { GenericQueryCtx } from "convex/server";
import type { AnalyticsQueryOptions, AnalyticsQueryResponse } from "@hexclave/shared/dist/interface/crud/analytics";
import { AsyncStoreProperty, GetCurrentPartialUserOptions, GetCurrentUserOptions } from "../../common";
import { CustomerProductsList, CustomerProductsRequestOptions, InlineProduct, ServerItem } from "../../customers";
import { DataVaultStore } from "../../data-vault";
@ -104,6 +105,8 @@ export type StackServerApp<HasTokenStore extends boolean = boolean, ProjectId ex
// END_PLATFORM
activateEmailCapacityBoost(): Promise<void>,
queryAnalytics(options: AnalyticsQueryOptions): Promise<AnalyticsQueryResponse>,
}
& AsyncStoreProperty<"user", [id: string], ServerUser | null, false>
& Omit<AsyncStoreProperty<"users", [], ServerUser[], true>, "listUsers" | "useUsers">

View File

@ -261,6 +261,37 @@ Request:
Does not error.
## queryAnalytics(options)
Arguments:
options.query: string - ClickHouse SQL query to run
options.params: Record<string, unknown>? - ClickHouse query parameters
options.timeout_ms: number? - max execution time in milliseconds
options.include_all_branches: bool? - unsupported; must be false or omitted
Returns:
{
result: Record<string, unknown>[],
query_id: string
}
Request:
POST /api/v1/analytics/query [server-only]
Body: {
query: string,
params?: Record<string, unknown>,
timeout_ms?: number,
include_all_branches?: false
}
Runs a read-only analytics query for the current project and branch. The API applies project and branch filtering through ClickHouse settings.
Errors:
AnalyticsQueryError
code: "ANALYTICS_QUERY_ERROR"
message: sanitized ClickHouse query error
## sendEmail(options)
Arguments: