mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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** |  |  | | **After** |  |  | <details> <summary>Full-page overview (both themes)</summary> | Light | Dark | |---|---| |  |  | </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 | |---|---| |  |  | <!-- 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:
parent
c5c853ccff
commit
d5d2f27a96
@ -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;
|
||||
@ -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(),
|
||||
}),
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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/")) {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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": []
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -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": []
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 } |
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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>,
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user