From d5d2f27a9626577a82d5b713c0d528b5239526f8 Mon Sep 17 00:00:00 2001 From: aadesh18 <110230993+aadesh18@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:42:10 -0700 Subject: [PATCH] Move queryAnalytics to server app (#1687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) |
Full-page overview (both themes) | Light | Dark | |---|---| | ![](https://gist.githubusercontent.com/aadesh18/e7950872b14557cfac823a99cb90ea9f/raw/analytics-after-light.png) | ![](https://gist.githubusercontent.com/aadesh18/e7950872b14557cfac823a99cb90ea9f/raw/analytics-after-dark.png) |
## 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) | ## 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. --- .../{internal => }/analytics/query/route.ts | 32 ++-- .../analytics/query/timing/route.ts | 12 +- .../route-handlers/smart-route-handler.tsx | 2 +- .../analytics/queries/page-client.tsx | 7 +- .../analytics/tables/query-data-grid.tsx | 10 +- .../query-analytics/page-client.tsx | 6 +- .../session-replays/page-client.tsx | 11 +- .../[projectId]/sign-up-rules/page-client.tsx | 6 +- .../teams/[teamId]/team-analytics.tsx | 7 +- .../projects/[projectId]/use-admin-app.tsx | 10 +- .../users/[userId]/user-analytics.tsx | 12 +- .../src/components/commands/run-query.tsx | 9 +- .../api/v1/analytics-events-batch.test.ts | 4 +- .../endpoints/api/v1/analytics-events.test.ts | 8 +- .../endpoints/api/v1/analytics-query.test.ts | 24 +-- .../api/v1/external-db-sync-advanced.test.ts | 4 +- .../api/v1/external-db-sync-basics.test.ts | 4 +- .../endpoints/api/v1/internal-metrics.test.ts | 4 +- .../v1/internal/sign-up-rules-stats.test.ts | 4 +- .../api/v1/token-refresh-events.test.ts | 4 +- docs-mintlify/openapi/admin.json | 175 +++++++++++++++++- docs-mintlify/openapi/server.json | 175 +++++++++++++++++- docs/content/docs/(guides)/apps/analytics.mdx | 10 + .../shared/src/interface/admin-interface.ts | 20 -- .../shared/src/interface/server-interface.ts | 20 ++ .../apps/implementations/admin-app-impl.ts | 5 - .../apps/implementations/server-app-impl.ts | 5 + .../hexclave-app/apps/interfaces/admin-app.ts | 2 - .../apps/interfaces/server-app.ts | 3 + sdks/spec/src/apps/server-app.spec.md | 31 ++++ 30 files changed, 512 insertions(+), 114 deletions(-) rename apps/backend/src/app/api/latest/{internal => }/analytics/query/route.ts (69%) rename apps/backend/src/app/api/latest/{internal => }/analytics/query/timing/route.ts (67%) diff --git a/apps/backend/src/app/api/latest/internal/analytics/query/route.ts b/apps/backend/src/app/api/latest/analytics/query/route.ts similarity index 69% rename from apps/backend/src/app/api/latest/internal/analytics/query/route.ts rename to apps/backend/src/app/api/latest/analytics/query/route.ts index ca1daff07..9e67fbf20 100644 --- a/apps/backend/src/app/api/latest/internal/analytics/query/route.ts +++ b/apps/backend/src/app/api/latest/analytics/query/route.ts @@ -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[]>(); + const rows = await resultSet.data.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; diff --git a/apps/backend/src/app/api/latest/internal/analytics/query/timing/route.ts b/apps/backend/src/app/api/latest/analytics/query/timing/route.ts similarity index 67% rename from apps/backend/src/app/api/latest/internal/analytics/query/timing/route.ts rename to apps/backend/src/app/api/latest/analytics/query/timing/route.ts index 03d41a4c4..b298ccf31 100644 --- a/apps/backend/src/app/api/latest/internal/analytics/query/timing/route.ts +++ b/apps/backend/src/app/api/latest/analytics/query/timing/route.ts @@ -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(), }), diff --git a/apps/backend/src/route-handlers/smart-route-handler.tsx b/apps/backend/src/route-handlers/smart-route-handler.tsx index 4c954cb3a..ed439fd99 100644 --- a/apps/backend/src/route-handlers/smart-route-handler.tsx +++ b/apps/backend/src/route-handlers/smart-route-handler.tsx @@ -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", diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx index a447d45a8..522dfea23 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx @@ -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); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/query-data-grid.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/query-data-grid.tsx index e4cf2d46e..6b1141158 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/query-data-grid.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/query-data-grid.tsx @@ -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 }, ref, ) { - const adminApp = useAdminApp(); + const serverApp = useServerApp(); const [discoveredColumns, setDiscoveredColumns] = useState([]); const [error, setError] = useState(null); const [selectedRow, setSelectedRow] = useState(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([]); @@ -462,7 +462,7 @@ export const QueryDataGrid = forwardRef 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 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]; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/query-analytics/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/query-analytics/page-client.tsx index 06457ee67..7556eea55 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/query-analytics/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/query-analytics/page-client.tsx @@ -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( @@ -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 () => { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx index 9e5b16e8c..f8b337556 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx @@ -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([]); 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, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/[teamId]/team-analytics.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/[teamId]/team-analytics.tsx index 1aedaffea..772fb4f88 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/[teamId]/team-analytics.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/[teamId]/team-analytics.tsx @@ -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) => - 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 (
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx index 3c1dd0d07..feff2a153 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx @@ -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 | 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 { + return useAdminApp(projectId); +} + export function useProjectId() { const pathname = usePathname(); if (!pathname.startsWith("/projects/")) { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/user-analytics.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/user-analytics.tsx index 53e95194a..380fa15b8 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/user-analytics.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/user-analytics.tsx @@ -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({ status: "loading" }); const [recent, setRecent] = useState({ rows: [], hasMore: false, isLoadingMore: false }); const recentContextRef = useRef(null); @@ -503,7 +503,7 @@ export function UserAnalyticsSection({ user, dayFilter, onClearDayFilter }: User }; const runQuery = (query: string, params: Record) => - 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 (
diff --git a/apps/dashboard/src/components/commands/run-query.tsx b/apps/dashboard/src/components/commands/run-query.tsx index 31dfb588c..13345d510 100644 --- a/apps/dashboard/src/components/commands/run-query.tsx +++ b/apps/dashboard/src/components/commands/run-query.tsx @@ -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([]); const [rows, setRows] = useState([]); const [error, setError] = useState(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({ diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts index 6401ddb96..a8c1ebe30 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts @@ -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 }, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events.test.ts index 9e5cc5c5c..cc46dee25 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events.test.ts @@ -17,9 +17,9 @@ const stripQueryId = 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 diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts index 9e6679620..0c1f1324d 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts @@ -9,9 +9,9 @@ import { waitForItemQuantityToReach } from "../../../payment-quota-helpers"; async function runQuery(body: { query: string, params?: Record, 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 = }; 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, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts index d458a6321..eea1ca3fe 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts @@ -22,9 +22,9 @@ import { const COMPLEX_SEQUENCE_TIMEOUT = TEST_TIMEOUT * 2 + 30_000; async function runQueryForCurrentProject(body: { query: string, params?: Record, 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, }); } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts index 312dca325..6054eec80 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts @@ -38,9 +38,9 @@ import { } from './external-db-sync-utils'; async function runQueryForCurrentProject(body: { query: string, params?: Record, 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, }); } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts index d6a270498..d66357041 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal-metrics.test.ts @@ -103,9 +103,9 @@ async function waitForAnalyticsRowsForSessionReplaySegment( expectedCount: number, ): Promise { 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 diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/sign-up-rules-stats.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/sign-up-rules-stats.test.ts index 5e5261d40..fcf18add3 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/sign-up-rules-stats.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/sign-up-rules-stats.test.ts @@ -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 diff --git a/apps/e2e/tests/backend/endpoints/api/v1/token-refresh-events.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/token-refresh-events.test.ts index 47b1fcb74..ae831f9ab 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/token-refresh-events.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/token-refresh-events.test.ts @@ -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 diff --git a/docs-mintlify/openapi/admin.json b/docs-mintlify/openapi/admin.json index 01c2681f8..16fa08605 100644 --- a/docs-mintlify/openapi/admin.json +++ b/docs-mintlify/openapi/admin.json @@ -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": [] } } }, diff --git a/docs-mintlify/openapi/server.json b/docs-mintlify/openapi/server.json index 3957962cd..fc9b7a94c 100644 --- a/docs-mintlify/openapi/server.json +++ b/docs-mintlify/openapi/server.json @@ -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": [] } } }, diff --git a/docs/content/docs/(guides)/apps/analytics.mdx b/docs/content/docs/(guides)/apps/analytics.mdx index 672274430..22b8faedc 100644 --- a/docs/content/docs/(guides)/apps/analytics.mdx +++ b/docs/content/docs/(guides)/apps/analytics.mdx @@ -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." diff --git a/packages/shared/src/interface/admin-interface.ts b/packages/shared/src/interface/admin-interface.ts index 6d08f67ba..78b393f73 100644 --- a/packages/shared/src/interface/admin-interface.ts +++ b/packages/shared/src/interface/admin-interface.ts @@ -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 { - 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 { const qs = new URLSearchParams(); if (options?.status) qs.set('status', options.status); diff --git a/packages/shared/src/interface/server-interface.ts b/packages/shared/src/interface/server-interface.ts index 5bfc03279..7723ef92f 100644 --- a/packages/shared/src/interface/server-interface.ts +++ b/packages/shared/src/interface/server-interface.ts @@ -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 { + 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 } | diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts index 6234d69bd..7f1d26189 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts @@ -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 { - return await this._interface.queryAnalytics(options); - } - async getAnalyticsClickmap(options: AnalyticsClickmapOptions): Promise { return await this._interface.getAnalyticsClickmap({ kind: options.kind, diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts index 363ff2424..c931ec3a0 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts @@ -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 { + return await this._interface.queryAnalytics(options); + } + protected override async _refreshSession(session: InternalSession) { await Promise.all([ super._refreshUser(session), diff --git a/packages/template/src/lib/hexclave-app/apps/interfaces/admin-app.ts b/packages/template/src/lib/hexclave-app/apps/interfaces/admin-app.ts index cf497ba74..ba10f3bd0 100644 --- a/packages/template/src/lib/hexclave-app/apps/interfaces/admin-app.ts +++ b/packages/template/src/lib/hexclave-app/apps/interfaces/admin-app.ts @@ -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, - queryAnalytics(options: AnalyticsQueryOptions): Promise, getAnalyticsClickmap(options: AnalyticsClickmapOptions): Promise, createAnalyticsClickmapToken(options: { origin: string }): Promise, diff --git a/packages/template/src/lib/hexclave-app/apps/interfaces/server-app.ts b/packages/template/src/lib/hexclave-app/apps/interfaces/server-app.ts index 1428ed0f3..03aa82fe3 100644 --- a/packages/template/src/lib/hexclave-app/apps/interfaces/server-app.ts +++ b/packages/template/src/lib/hexclave-app/apps/interfaces/server-app.ts @@ -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, + + queryAnalytics(options: AnalyticsQueryOptions): Promise, } & AsyncStoreProperty<"user", [id: string], ServerUser | null, false> & Omit, "listUsers" | "useUsers"> diff --git a/sdks/spec/src/apps/server-app.spec.md b/sdks/spec/src/apps/server-app.spec.md index 6e7fa6a32..36f486b3c 100644 --- a/sdks/spec/src/apps/server-app.spec.md +++ b/sdks/spec/src/apps/server-app.spec.md @@ -261,6 +261,37 @@ Request: Does not error. +## queryAnalytics(options) + +Arguments: + options.query: string - ClickHouse SQL query to run + options.params: Record? - 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[], + query_id: string + } + +Request: + POST /api/v1/analytics/query [server-only] + Body: { + query: string, + params?: Record, + 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: