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: