Skip to content

Commit 5573927

Browse files
authored
Ask AI Huge Response (#1328)
This PR fixes the bug where analytics tool returns a lot of rows, which results in huge token count. We do it by checking the number of characters in the tool call, and if it is more than 50000 characters, we send an error message rather than the rows and ask the ai to make more focused queries. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * AI assistant shows friendlier, categorized error messages and captures unexpected errors for diagnosis. * UI now displays classifier-derived, user-friendly AI error text. * **Bug Fixes & Improvements** * Enforced a hard size budget for SQL query results and gracefully handles oversized responses. * Centralized safer database error messaging to avoid leaking internal details. * Strengthened AI guidance to prefer narrower queries, safer column selection, and pairing GROUP BY with ORDER BY + LIMIT. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent c46767f commit 5573927

6 files changed

Lines changed: 155 additions & 67 deletions

File tree

apps/backend/src/app/api/latest/internal/analytics/query/route.ts

Lines changed: 2 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { getClickhouseExternalClient } from "@/lib/clickhouse";
2+
import { getSafeClickhouseErrorMessage } from "@/lib/clickhouse-errors";
23
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
34
import { KnownErrors } from "@stackframe/stack-shared";
45
import { adaptSchema, adminAuthTypeSchema, jsonSchema, yupBoolean, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields";
5-
import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
6-
import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
6+
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
77
import { Result } from "@stackframe/stack-shared/dist/utils/results";
88
import { randomUUID } from "crypto";
99

@@ -72,45 +72,5 @@ export const POST = createSmartRouteHandler({
7272
},
7373
});
7474

75-
const SAFE_CLICKHOUSE_ERROR_CODES = [
76-
62, // SYNTAX_ERROR
77-
159, // TIMEOUT_EXCEEDED
78-
164, // READONLY
79-
158, // TOO_MANY_ROWS
80-
396, // TOO_MANY_ROWS_OR_BYTES
81-
636, // CANNOT_EXTRACT_TABLE_STRUCTURE
82-
];
83-
84-
const UNSAFE_CLICKHOUSE_ERROR_CODES = [
85-
36, // BAD_ARGUMENTS
86-
43, // ILLEGAL_TYPE_OF_ARGUMENT
87-
47, // UNKNOWN_IDENTIFIER
88-
60, // UNKNOWN_TABLE
89-
497, // ACCESS_DENIED
90-
];
91-
92-
const DEFAULT_CLICKHOUSE_ERROR_MESSAGE = "Error during execution of this query.";
9375
const MAX_RESULT_ROWS = 10_000;
9476
const MAX_RESULT_BYTES = 10 * 1024 * 1024;
95-
96-
function getSafeClickhouseErrorMessage(error: unknown, query: string) {
97-
if (typeof error !== "object" || error === null || !("code" in error) || typeof error.code !== "string" || isNaN(Number(error.code)) || !("message" in error) || typeof error.message !== "string") {
98-
captureError("unknown-clickhouse-error-for-query-not-clickhouse-error", new StackAssertionError("Unknown error from Clickhouse is not a Clickhouse error", { cause: error, query: query }));
99-
return DEFAULT_CLICKHOUSE_ERROR_MESSAGE;
100-
}
101-
102-
const errorCode = Number(error.code);
103-
const message = error.message;
104-
if (SAFE_CLICKHOUSE_ERROR_CODES.includes(errorCode)) {
105-
return message;
106-
}
107-
const isKnown = UNSAFE_CLICKHOUSE_ERROR_CODES.includes(errorCode);
108-
if (!isKnown) {
109-
captureError("unknown-clickhouse-error-for-query", new StackAssertionError(`Unknown Clickhouse error: code ${errorCode} not in safe or unsafe codes`, { cause: error, query: query }));
110-
}
111-
112-
if (getNodeEnvironment() === "development" || getNodeEnvironment() === "test") {
113-
return `${DEFAULT_CLICKHOUSE_ERROR_MESSAGE}${!isKnown ? "\n\nThis error is not known and you should probably add it to the safe or unsafe codes in analytics/query/route.ts." : ""}\n\nAs you are in development mode, you can see the full error: ${errorCode} ${message}`;
114-
}
115-
return DEFAULT_CLICKHOUSE_ERROR_MESSAGE;
116-
}

apps/backend/src/lib/ai/prompts.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { SQL_QUERY_RESULT_MAX_CHARS } from "@/lib/ai/tools/sql-query";
2+
13
/**
24
* Base prompt for all Stack Auth AI interactions.
35
* Contains global guidelines and core knowledge about Stack Auth.
@@ -102,6 +104,44 @@ SQL QUERY GUIDELINES:
102104
- Recent signups: SELECT * FROM users ORDER BY signed_up_at DESC LIMIT 10
103105
- Events today: SELECT COUNT(*) FROM events WHERE toDate(event_at) = today()
104106
- Event types: SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type ORDER BY count DESC LIMIT 10
107+
108+
TOOL RESULT BUDGET (HARD LIMIT):
109+
- The queryAnalytics tool returns { success: false } if the result JSON exceeds ${SQL_QUERY_RESULT_MAX_CHARS.toLocaleString()} characters.
110+
NO ROWS reach you in that case — you get { success: false, error, rowCount, characters, columnsReturned }
111+
and you MUST re-query with a more specific SQL statement.
112+
- The events.data JSON blob typically triples per-row cost. Never SELECT * on events unless you have
113+
a very small LIMIT and truly need every column.
114+
115+
PREFER AGGREGATION OVER RAW ROWS:
116+
For "how many", "top N", "distribution", "unique count", "average", "over time" questions,
117+
push the math into SQL using ClickHouse functions. Examples:
118+
119+
Count: SELECT COUNT(*) FROM events WHERE event_type='$token-refresh' AND event_at >= today()
120+
Distinct count: SELECT uniqExact(user_id) FROM events WHERE event_at >= today() - INTERVAL 7 DAY
121+
Top N: SELECT user_id, COUNT(*) AS c FROM events GROUP BY user_id ORDER BY c DESC LIMIT 10
122+
Quantiles: SELECT quantile(0.5)(c), quantile(0.95)(c) FROM (SELECT user_id, COUNT(*) AS c FROM events GROUP BY user_id)
123+
Time bucketing: SELECT toStartOfHour(event_at) AS bucket, COUNT(*) AS c FROM events
124+
WHERE event_at >= now() - INTERVAL 1 DAY GROUP BY bucket ORDER BY bucket
125+
JSON key discovery: SELECT arrayJoin(JSONExtractKeys(data)) AS k, COUNT(*) AS c FROM events
126+
GROUP BY k ORDER BY c DESC LIMIT 20
127+
Multi-metric: SELECT COUNT(*), uniqExact(user_id), min(event_at), max(event_at)
128+
FROM events WHERE event_type='$token-refresh'
129+
130+
WHEN INDIVIDUAL ROWS MATTER (user explicitly asked to see records):
131+
- ALWAYS use LIMIT <= 50.
132+
- ALWAYS specify the exact columns you need — never SELECT * on events.
133+
- Drop the 'data' column unless the user specifically asked about event payloads.
134+
135+
GROUP BY REQUIRES ORDER BY + LIMIT unless you expect <= 50 groups, otherwise the result may
136+
exceed the ${SQL_QUERY_RESULT_MAX_CHARS.toLocaleString()}-character budget and fail.
137+
138+
HANDLING { success: false } ERRORS:
139+
When the tool returns success:false with "Result too large":
140+
1. Read rowCount — if it's large (>100), switch to aggregation (COUNT, uniqExact, GROUP BY...).
141+
2. Read columnsReturned — if it includes 'data', re-query without it.
142+
3. Re-query with a narrower WHERE clause or a smaller LIMIT.
143+
4. Do NOT present the error to the user — fix the query and try again.
144+
5. Do NOT claim you saw rows that you didn't — the error response contains no row data.
105145
`,
106146
"docs-ask-ai": `
107147
# Stack Auth AI Assistant System Prompt
Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { getClickhouseExternalClient } from "@/lib/clickhouse";
2+
import { getSafeClickhouseErrorMessage } from "@/lib/clickhouse-errors";
23
import { SmartRequestAuth } from "@/route-handlers/smart-request";
4+
import { ClickHouseError } from "@clickhouse/client";
35
import { tool } from "ai";
46
import { z } from "zod";
57

8+
export const SQL_QUERY_RESULT_MAX_CHARS = 50_000;
9+
610
export function createSqlQueryTool(auth: SmartRequestAuth | null, targetProjectId?: string | null) {
711
if (auth == null) {
812
// Return null or throw - analytics queries require authentication
@@ -21,32 +25,48 @@ export function createSqlQueryTool(auth: SmartRequestAuth | null, targetProjectI
2125
}),
2226
execute: async ({ query }: { query: string }) => {
2327
const client = getClickhouseExternalClient();
24-
return await client.query({
25-
query,
26-
clickhouse_settings: {
27-
SQL_project_id: projectId,
28-
SQL_branch_id: branchId,
29-
max_execution_time: 5,
30-
readonly: "1",
31-
allow_ddl: 0,
32-
max_result_rows: "10000",
33-
max_result_bytes: (10 * 1024 * 1024).toString(),
34-
result_overflow_mode: "throw",
35-
},
36-
format: "JSONEachRow",
37-
})
38-
.then(async (resultSet) => {
39-
const rows = await resultSet.json<Record<string, unknown>[]>();
28+
try {
29+
const resultSet = await client.query({
30+
query,
31+
clickhouse_settings: {
32+
SQL_project_id: projectId,
33+
SQL_branch_id: branchId,
34+
max_execution_time: 5,
35+
readonly: "1",
36+
allow_ddl: 0,
37+
max_result_rows: "10000",
38+
max_result_bytes: (10 * 1024 * 1024).toString(),
39+
result_overflow_mode: "throw",
40+
},
41+
format: "JSONEachRow",
42+
});
43+
const rows = await resultSet.json<Record<string, unknown>[]>();
44+
const response = { success: true as const, rowCount: rows.length, result: rows };
45+
const serialized = JSON.stringify(response);
46+
if (serialized.length > SQL_QUERY_RESULT_MAX_CHARS) {
4047
return {
41-
success: true as const,
48+
success: false as const,
49+
error:
50+
`Result too large: ${rows.length} rows, ${serialized.length} characters (limit ${SQL_QUERY_RESULT_MAX_CHARS}). ` +
51+
`To fix: ` +
52+
`(1) Use aggregation (COUNT, uniqExact, GROUP BY, topK, quantile) instead of fetching rows. ` +
53+
`(2) If you need rows, add a WHERE clause or reduce LIMIT. ` +
54+
`(3) Select only the columns you need — avoid the 'data' column on events unless essential.`,
4255
rowCount: rows.length,
43-
result: rows,
56+
characters: serialized.length,
57+
columnsReturned: rows.length > 0 ? Object.keys(rows[0]) : [],
4458
};
45-
})
46-
.catch((error: unknown) => ({
59+
}
60+
return response;
61+
} catch (error) {
62+
if (!(error instanceof ClickHouseError)) {
63+
throw error;
64+
}
65+
return {
4766
success: false as const,
48-
error: error instanceof Error ? error.message : "Query failed",
49-
}));
67+
error: getSafeClickhouseErrorMessage(error, query),
68+
};
69+
}
5070
},
5171
});
5272
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
2+
import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
3+
4+
const SAFE_CLICKHOUSE_ERROR_CODES = [
5+
62, // SYNTAX_ERROR
6+
159, // TIMEOUT_EXCEEDED
7+
164, // READONLY
8+
158, // TOO_MANY_ROWS
9+
396, // TOO_MANY_ROWS_OR_BYTES
10+
636, // CANNOT_EXTRACT_TABLE_STRUCTURE
11+
];
12+
13+
const UNSAFE_CLICKHOUSE_ERROR_CODES = [
14+
36, // BAD_ARGUMENTS
15+
43, // ILLEGAL_TYPE_OF_ARGUMENT
16+
47, // UNKNOWN_IDENTIFIER
17+
60, // UNKNOWN_TABLE
18+
497, // ACCESS_DENIED
19+
];
20+
21+
const DEFAULT_CLICKHOUSE_ERROR_MESSAGE = "Error during execution of this query.";
22+
23+
export function getSafeClickhouseErrorMessage(error: unknown, query: string) {
24+
if (typeof error !== "object" || error === null || !("code" in error) || typeof error.code !== "string" || isNaN(Number(error.code)) || !("message" in error) || typeof error.message !== "string") {
25+
captureError("unknown-clickhouse-error-for-query-not-clickhouse-error", new StackAssertionError("Unknown error from Clickhouse is not a Clickhouse error", { cause: error, query: query }));
26+
return DEFAULT_CLICKHOUSE_ERROR_MESSAGE;
27+
}
28+
29+
const errorCode = Number(error.code);
30+
const message = error.message;
31+
if (SAFE_CLICKHOUSE_ERROR_CODES.includes(errorCode)) {
32+
return message;
33+
}
34+
const isKnown = UNSAFE_CLICKHOUSE_ERROR_CODES.includes(errorCode);
35+
if (!isKnown) {
36+
captureError("unknown-clickhouse-error-for-query", new StackAssertionError(`Unknown Clickhouse error: code ${errorCode} not in safe or unsafe codes`, { cause: error, query: query }));
37+
}
38+
39+
if (getNodeEnvironment() === "development" || getNodeEnvironment() === "test") {
40+
return `${DEFAULT_CLICKHOUSE_ERROR_MESSAGE}${!isKnown ? "\n\nThis error is not known and you should probably add it to the safe or unsafe codes in clickhouse-errors.ts." : ""}\n\nAs you are in development mode, you can see the full error: ${errorCode} ${message}`;
41+
}
42+
return DEFAULT_CLICKHOUSE_ERROR_MESSAGE;
43+
}

apps/dashboard/src/components/commands/ai-chat-shared.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import { buildStackAuthHeaders, type CurrentUser } from "@/lib/api-headers";
33
import { getPublicEnvVar } from "@/lib/env";
44
import type { UIMessage } from "@ai-sdk/react";
55
import { ArrowSquareOutIcon, CaretDownIcon, CheckIcon, CopyIcon, DatabaseIcon, SparkleIcon, SpinnerGapIcon, UserIcon } from "@phosphor-icons/react";
6-
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
6+
import { captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
77
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
88
import { convertToModelMessages, DefaultChatTransport } from "ai";
99
import { memo, useCallback, useEffect, useRef, useState } from "react";
1010
import ReactMarkdown from "react-markdown";
1111
import remarkGfm from "remark-gfm";
1212

13+
1314
export function createAskAiTransport({
1415
currentUser,
1516
projectId,
@@ -532,3 +533,26 @@ export function useWordStreaming(content: string) {
532533
isRevealing: displayedWordCount < targetWordCount,
533534
};
534535
}
536+
537+
538+
// Classifies raw AI provider errors into user-friendly messages.
539+
// The raw error is captured to Sentry separately via captureError — never shown to the user.
540+
export function getFriendlyAiErrorMessage(error: Error): string {
541+
const causeMessage = (error as { cause?: { message?: string } }).cause?.message ?? "";
542+
const blob = `${error.message} ${causeMessage}`;
543+
if (/maximum context length|context_length_exceeded|too many tokens|context length/i.test(blob)) {
544+
return "The conversation got too long. Try starting a new chat or asking a more focused question.";
545+
}
546+
if (/rate limit|429|quota|too many requests/i.test(blob)) {
547+
return "Service is busy. Please try again in a moment.";
548+
}
549+
if (/timeout|ECONNRESET|fetch failed|network/i.test(blob)) {
550+
return "Request timed out. Please try again.";
551+
}
552+
if (/result too large|limit \d+/i.test(blob)) {
553+
return "The query returned too much data. Try narrowing your question or requesting fewer rows.";
554+
}
555+
// Unclassified — this is unexpected, report it
556+
captureError("ask-ai", error);
557+
return "Something went wrong. Please try again.";
558+
}

apps/dashboard/src/components/commands/ask-ai.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ import { CmdKPreviewProps } from "../cmdk-commands";
1010
import {
1111
AssistantMessage,
1212
createAskAiTransport,
13+
getFriendlyAiErrorMessage,
1314
getMessageContent,
1415
getToolInvocations,
1516
UserMessage,
16-
useWordStreaming,
17+
useWordStreaming
1718
} from "./ai-chat-shared";
1819

1920

@@ -216,7 +217,7 @@ const AIChatPreviewInner = memo(function AIChatPreview({
216217
{aiError && (
217218
<div className="flex items-start gap-2 text-[12px] text-red-400/90 px-3 py-2 bg-red-500/[0.08] rounded-lg ring-1 ring-red-500/20">
218219
<span className="shrink-0 mt-0.5"></span>
219-
<span>{aiError.message || "Failed to get response. Please try again."}</span>
220+
<span>{getFriendlyAiErrorMessage(aiError)}</span>
220221
</div>
221222
)}
222223
</div>

0 commit comments

Comments
 (0)