diff --git a/packages/ai/src/lib/tracing.ts b/packages/ai/src/lib/tracing.ts index f80dc3882..853fc756c 100644 --- a/packages/ai/src/lib/tracing.ts +++ b/packages/ai/src/lib/tracing.ts @@ -13,6 +13,29 @@ export function mergeWideEvent>( log.info({ service: "api", ...payload }); } +export function captureWarning>( + error: unknown, + fields?: Partial +): void { + const err = error instanceof Error ? error : new Error(String(error)); + const payload = fields as Record | undefined; + const requestLog = getActiveAiRequestLogger(); + if (requestLog) { + if (payload) { + requestLog.warn(err.message, payload); + } else { + requestLog.warn(err.message); + } + return; + } + log.warn({ + service: "api", + error_message: err.message, + ...(err.stack ? { error_stack: err.stack } : {}), + ...(payload ?? {}), + }); +} + export function captureError>( error: unknown, fields?: Partial diff --git a/packages/ai/src/query/batch-executor.test.ts b/packages/ai/src/query/batch-executor.test.ts index ab205495d..7ae461600 100644 --- a/packages/ai/src/query/batch-executor.test.ts +++ b/packages/ai/src/query/batch-executor.test.ts @@ -1,5 +1,7 @@ import { chQuery } from "@databuddy/db/clickhouse"; +import type { RequestLogger } from "evlog"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setAiRequestLoggerProvider } from "../lib/request-logger"; import { areQueriesCompatible, buildUnionQuery, @@ -55,6 +57,7 @@ function transientClickHouseError(): Error { beforeEach(() => { mockChQuery.mockReset(); + setAiRequestLoggerProvider(null); }); describe("batch-executor schema signatures", () => { @@ -182,6 +185,47 @@ describe("executeBatch single query retry", () => { }); }); +describe("executeBatch union fallback logging", () => { + it("logs batch union fallback as a warning when single queries recover", async () => { + const mockLogger = { + error: vi.fn(), + set: vi.fn(), + warn: vi.fn(), + } as unknown as RequestLogger; + setAiRequestLoggerProvider(() => mockLogger); + mockChQuery + .mockRejectedValueOnce(new Error("Union query failed")) + .mockResolvedValueOnce([{ name: "/", pageviews: 2, visitors: 1 }]) + .mockResolvedValueOnce([{ name: "/pricing", pageviews: 1, visitors: 1 }]); + + const results = await executeBatch([ + { ...singleQueryRequest, type: "top_pages" }, + { ...singleQueryRequest, type: "top_pages" }, + ]); + + expect(chQuery).toHaveBeenCalledTimes(3); + expect(mockLogger.warn).toHaveBeenCalledWith( + "Union query failed", + expect.objectContaining({ + batch_size: 2, + batch_types: "top_pages,top_pages", + operation: "batch_union", + }) + ); + expect(mockLogger.error).not.toHaveBeenCalled(); + expect(results).toEqual([ + { + type: "top_pages", + data: [{ name: "/", pageviews: 2, visitors: 1 }], + }, + { + type: "top_pages", + data: [{ name: "/pricing", pageviews: 1, visitors: 1 }], + }, + ]); + }); +}); + describe("buildUnionQuery compile isolation", () => { const baseRequest = { projectId: "test-website", diff --git a/packages/ai/src/query/batch-executor.ts b/packages/ai/src/query/batch-executor.ts index 6b2d7ea22..4e80dd2a6 100644 --- a/packages/ai/src/query/batch-executor.ts +++ b/packages/ai/src/query/batch-executor.ts @@ -1,5 +1,5 @@ import { chQuery } from "@databuddy/db/clickhouse"; -import { captureError, mergeWideEvent } from "../lib/tracing"; +import { captureWarning, mergeWideEvent } from "../lib/tracing"; import { QueryBuilders, suggestQueryTypes } from "./builders"; import { getClickHouseQuerySettings, @@ -529,15 +529,18 @@ export async function executeBatch( } return { unionCount: 1, singleCount: 0 }; } catch (error) { - captureError(error, { + const batchUnionError = + error instanceof Error ? error.message : "Union query failed"; + captureWarning(error, { operation: "batch_union", batch_types: compiledItems.map((g) => g.req.type).join(","), batch_size: compiledItems.length, + batch_union_fallback: 1, + batch_union_error: batchUnionError, }); mergeWideEvent({ batch_union_fallback: 1, - batch_union_error: - error instanceof Error ? error.message : "Union query failed", + batch_union_error: batchUnionError, }); for (const { index, req } of compiledItems) { results[index] = await runSingle(req, opts);