diff --git a/packages/platform-lite/src/management-api/debugging.ts b/packages/platform-lite/src/management-api/debugging.ts index ef09b70b..7938b843 100644 --- a/packages/platform-lite/src/management-api/debugging.ts +++ b/packages/platform-lite/src/management-api/debugging.ts @@ -41,6 +41,55 @@ export function createDebuggingRoutes( } }); + // Current mcp (>= the #326 ClickHouse migration): GET /analytics/endpoints/logs + // with ClickHouse-dialect sql over the unified 'logs' stream. The 'logs' VIEW + // (log-seeding.ts) provides the shape; only map access and countIf need + // translating. iso_timestamp_start/end are accepted but IGNORED on purpose: + // scenario seeds carry fixed dates, while mcp defaults the window from the + // current clock — a faithful filter would empty every scenario (the legacy + // logs.all route makes the same choice). + routes.get('/v1/projects/:ref/analytics/endpoints/logs', async (c) => { + const { ref } = c.req.param(); + const project = store.get(ref); + if (!project) return c.json({ message: 'Project not found' }, 404); + + const sql = + c.req.query('sql') ?? + "select id, timestamp, event_message from logs where source = 'edge_logs' order by timestamp desc limit 100"; + + // The hosted endpoint is read-only server-side; enforce the same contract on + // model-authored SQL. The prefix check only shapes the error message — the + // REAL enforcement is the read-only transaction below, which postgres applies + // to every statement including data-modifying CTEs. The 400 body carries + // `message` because mcp's assertSuccess parses non-2xx bodies as {message} + // (the management-API error envelope) — without it the model only sees the + // generic "Failed to fetch logs" fallback; `error` kept for shape + // consistency with the 200 SQL-error path. + const stmt = sql.trim().replace(/;+\s*$/, ''); + if (stmt.includes(';') || !/^\s*(select|with)\b/i.test(stmt)) { + const message = 'only a single read-only SELECT statement is supported'; + return c.json({ result: [], error: message, message }, 400); + } + + try { + const compiled = compileClickHouseLogsSql(stmt); + const result = await project.logsDb.transaction(async (tx) => { + await tx.exec('SET TRANSACTION READ ONLY'); + // logs_reader may SELECT only the unified 'logs' view (grant in + // LOGS_BASE_SQL) — postgres name resolution denies the backing tables + // under ANY spelling (edge_logs, public.edge_logs, "edge_logs"), + // which the best-effort regex in compileClickHouseLogsSql cannot. + // SET LOCAL reverts with the transaction. + await tx.exec('SET LOCAL ROLE logs_reader'); + return tx.query>(compiled); + }); + return c.json({ result: result.rows }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return c.json({ result: [], error: message }); + } + }); + routes.get('/v1/projects/:ref/advisors/security', async (c) => { const { ref } = c.req.param(); const project = store.get(ref); @@ -84,6 +133,74 @@ export function createDebuggingRoutes( return routes; } +/** + * Translate ClickHouse-dialect SQL (as current mcp emits for the unified logs + * stream) into PGlite SQL against the 'logs' VIEW. The supported surface is + * DELIBERATELY partial — exactly what models have been observed to emit: + * log_attributes['k'] -> (log_attributes->>'k') (always text — hosted + * ClickHouse map values are String too, so a bare numeric comparison like + * log_attributes['status'] >= 400 errors here exactly as it does there; + * models adapt by wrapping in toInt32OrZero, as observed) + * countIf(cond) -> count(*) FILTER (WHERE cond) + * toInt32OrZero/toInt64OrZero/toUInt32OrZero/toString -> SQL shims in + * LOGS_BASE_SQL (log-seeding.ts) + * Anything else surfaces the raw SQL error to the model, which adapts — fine + * for exploration, but remember: a query that only works here (postgres-isms) + * would FAIL against the hosted ClickHouse endpoint, and vice versa. Extend + * only from observed model output, never speculatively. + * + * UNMODELED sources error loudly: workflow_run_logs (branch-action preset) and + * realtime_logs (realtime preset) have no backing table in platform-lite, so a + * query naming them throws here instead of silently returning 0 rows — an + * empty result would read as "no logs", green-lighting an eval the fixture + * cannot actually serve. The error surfaces to the model like any other. + * + * ONLY the unified 'logs' relation is queryable, matching mcp's contract + * (nothing else is described); locally the backing tables share the PGlite + * db, so a direct `from edge_logs` would succeed here while failing hosted. + * ENFORCEMENT is the logs_reader role in the route's transaction (postgres + * resolves every spelling — qualified, quoted). The FROM/JOIN regex below is + * best-effort message shaping for the common unqualified form, pointing the + * model at the source-filter idiom; bypassing it just yields the role's + * "permission denied" instead. Source names remain valid as string literals + * (where source = 'edge_logs'). + * + * PROVENANCE: none of this is importable. The real dialect boundary lives in + * the hosted platform's Logflare/ClickHouse backend (supabase/platform#35096, + * platform-internal); mcp ships only tool descriptions, and ClickHouse + * builtins have no npm artifact. The verbatim SQL in debugging.test.ts is + * deliberately FROZEN observed output (regression fixtures) - importing live + * definitions would make those contract tests follow the thing they test. + * + * KNOWN LIMITATION (time semantics): iso_timestamp_start/end are ignored + * (fixed-date seeds), so window-correctness of model queries is NOT exercised + * locally. A time-window-discriminating eval needs relative-time seeding. + */ +const UNMODELED_SOURCES = /\b(workflow_run_logs|realtime_logs)\b/i; +const PHYSICAL_RELATIONS = + /\b(?:from|join)\s+(edge_logs|function_edge_logs|function_logs|postgres_logs|auth_logs|storage_logs)\b/i; + +export function compileClickHouseLogsSql(sql: string): string { + const unmodeled = UNMODELED_SOURCES.exec(sql); + if (unmodeled) { + throw new Error( + `source '${unmodeled[1]}' is not modeled by platform-lite — no backing table, so results would be silently empty` + ); + } + const physical = PHYSICAL_RELATIONS.exec(sql); + if (physical) { + throw new Error( + `relation '${physical[1]}' is not queryable on this endpoint — query the unified 'logs' stream and filter with where source = '${physical[1]}'` + ); + } + return sql + .replace( + /\blog_attributes\['([^']+)'\]/gi, + (_m, key: string) => `(log_attributes->>'${key}')` + ) + .replace(/\bcountIf\s*\(/gi, 'count(*) FILTER (WHERE '); +} + function compileLogsSql(sql: string): string { let compiled = sql.trim(); diff --git a/packages/platform-lite/src/management-api/openapi.json b/packages/platform-lite/src/management-api/openapi.json index 67d8f5dc..50efcc77 100644 --- a/packages/platform-lite/src/management-api/openapi.json +++ b/packages/platform-lite/src/management-api/openapi.json @@ -984,7 +984,7 @@ "/v1/projects/{ref}/analytics/endpoints/logs.all": { "get": { "description": "Executes a SQL query on the project's logs.\n\nEither the `iso_timestamp_start` and `iso_timestamp_end` parameters must be provided.\nIf both are not provided, only the last 1 minute of logs will be queried.\nThe timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown.\n\nNote: Unless the `sql` parameter is provided, only edge_logs will be queried. See the [log query docs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer:~:text=logs%20from%20the-,Sources,-drop%2Ddown%3A) for all available sources. \n", - "operationId": "v1-get-project-logs", + "operationId": "v1-get-project-logs-all", "parameters": [ { "name": "ref", @@ -1076,6 +1076,105 @@ "x-oauth-scope": "analytics:read" } }, + "/v1/projects/{ref}/analytics/endpoints/logs": { + "get": { + "deprecated": false, + "description": "Executes an SQL or LQL query on the project's unified logs stream.\n\nEither the `iso_timestamp_start` and `iso_timestamp_end` parameters must be provided.\nIf both are not provided, only the last 1 minute of logs will be queried.\nThe timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown.\n\nFilter by the `source` column to specify specific log sources, such as edge_logs, postgres_logs, etc.\n\nNote: SQL must be written in **ClickHouse SQL dialect**.\n", + "operationId": "v1-get-project-logs", + "parameters": [ + { + "name": "ref", + "required": true, + "in": "path", + "description": "Project ref", + "schema": { + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst", + "type": "string" + } + }, + { + "name": "sql", + "required": false, + "in": "query", + "description": "Custom SQL query to execute on the logs. See [querying logs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer) for more details.", + "schema": { + "type": "string" + } + }, + { + "name": "iso_timestamp_start", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "example": "2025-03-01T00:00:00Z", + "type": "string" + } + }, + { + "name": "iso_timestamp_end", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "example": "2025-03-01T23:59:59Z", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "402": { + "description": "Usage exceeded. Enable additional usage to continue querying" + }, + "403": { + "description": "Forbidden action" + }, + "429": { + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Gets all project's logs in a single log stream", + "tags": [ + "Analytics" + ], + "x-badges": [ + { + "name": "OAuth scope: analytics:read", + "position": "after" + } + ], + "x-endpoint-owners": [ + "analytics" + ], + "x-fga-permissions": [ + [ + "analytics_logs_read" + ] + ], + "x-oauth-scope": "analytics:read" + } + }, "/v1/projects/{ref}/advisors/security": { "get": { "deprecated": true, diff --git a/packages/platform-lite/src/management-api/types.ts b/packages/platform-lite/src/management-api/types.ts index 036caf17..ac07b7ea 100644 --- a/packages/platform-lite/src/management-api/types.ts +++ b/packages/platform-lite/src/management-api/types.ts @@ -1265,6 +1265,34 @@ export interface paths { * * Note: Unless the `sql` parameter is provided, only edge_logs will be queried. See the [log query docs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer:~:text=logs%20from%20the-,Sources,-drop%2Ddown%3A) for all available sources. */ + get: operations["v1-get-project-logs-all"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/projects/{ref}/analytics/endpoints/logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Gets all project's logs in a single log stream + * @description Executes an SQL or LQL query on the project's unified logs stream. + * + * Either the `iso_timestamp_start` and `iso_timestamp_end` parameters must be provided. + * If both are not provided, only the last 1 minute of logs will be queried. + * The timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown. + * + * Filter by the `source` column to specify specific log sources, such as edge_logs, postgres_logs, etc. + * + * Note: SQL must be written in **ClickHouse SQL dialect**. + */ get: operations["v1-get-project-logs"]; put?: never; post?: never; @@ -9436,6 +9464,54 @@ export interface operations { }; }; }; + "v1-get-project-logs-all": { + parameters: { + query?: { + /** @description Custom SQL query to execute on the logs. See [querying logs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer) for more details. */ + sql?: string; + iso_timestamp_start?: string; + iso_timestamp_end?: string; + }; + header?: never; + path: { + /** @description Project ref */ + ref: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AnalyticsResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; "v1-get-project-logs": { parameters: { query?: { @@ -9468,6 +9544,13 @@ export interface operations { }; content?: never; }; + /** @description Usage exceeded. Enable additional usage to continue querying */ + 402: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Forbidden action */ 403: { headers: { diff --git a/packages/platform-lite/src/project/log-seeding.ts b/packages/platform-lite/src/project/log-seeding.ts index f1301a11..7e87ccc3 100644 --- a/packages/platform-lite/src/project/log-seeding.ts +++ b/packages/platform-lite/src/project/log-seeding.ts @@ -90,6 +90,95 @@ CREATE TABLE IF NOT EXISTS storage_logs ( level text, metadata jsonb NOT NULL DEFAULT '{}'::jsonb ); + +-- Unified ClickHouse-shaped stream: the hosted /analytics/endpoints/logs +-- endpoint exposes one 'logs' relation with a 'source' discriminator and a +-- log_attributes map. Mirror it so ClickHouse-dialect SQL from current mcp +-- (get_logs presets) and from mcp#333's query_logs runs with minimal +-- translation. Column-backed +-- attributes win over seeded metadata; nulls fall back to metadata keys. +-- +-- Provenance (hand-modeled; nothing usable is importable): +-- * The 'logs' relation shape is the hosted platform's Logflare/ClickHouse +-- contract. Hosted serves the /analytics/endpoints/logs route today; the +-- capabilities are landing per-PR (both in review as of 2026-07-21): +-- - supabase/platform#35096: logs.all.otel unified stream + ClickHouse +-- dialect for the getLogs PRESETS - what mcp main (post-#326) emits. +-- - supabase/platform#35970: custom-SQL passthrough (timestamps +-- normalized platform-side) - what mcp#333's query_logs (still open) +-- targets; current mcp main does not depend on it. +-- The fixture models both current main and the #333 validation branch +-- against that contract. It is +-- platform-internal either way: no npm package exports the schema, so +-- fixtures must model it by hand, exactly like every other platform-lite +-- emulation in this package. +-- * The 'source' names are the unified-stream sources referenced by mcp's +-- preset SQL and the query_logs sql description - not exported as data. +-- (mcp's /platform entrypoint DOES export logsServiceSchema, but that +-- enumerates service PRESETS, a different namespace; and the resolved +-- package (^0.8.1) can drift ahead of the MCP_SERVER_VERSION pin the +-- harness actually runs, so importing it would track the wrong artifact.) +-- Resync this view when the pinned MCP_SERVER_VERSION moves. +-- * UNMODELED preset sources: workflow_run_logs (branch-action) and +-- realtime_logs (realtime) have no backing table in platform-lite. +-- compileClickHouseLogsSql (debugging.ts) rejects queries naming them so +-- they error loudly instead of silently returning 0 rows; model them with +-- real tables + seeds before running a branch-action/realtime logs eval. +CREATE VIEW logs AS + SELECT id, identifier, timestamp, ts, event_message, message, level, level AS severity_text, 'edge_logs'::text AS source, + metadata || jsonb_strip_nulls(jsonb_build_object('identifier', identifier, 'request.method', method, 'request.path', path, 'response.status_code', status_code)) AS log_attributes + FROM edge_logs + UNION ALL + SELECT id, identifier, timestamp, ts, event_message, message, level, level, 'function_edge_logs', + metadata || jsonb_strip_nulls(jsonb_build_object('response.status_code', status_code, 'request.method', method, 'function_id', function_id, 'execution_time_ms', execution_time_ms, 'deployment_id', deployment_id, 'version', version)) + FROM function_edge_logs + UNION ALL + SELECT id, identifier, timestamp, ts, event_message, message, level, level, 'function_logs', + metadata || jsonb_strip_nulls(jsonb_build_object('level', level, 'function_id', function_id, 'deployment_id', deployment_id, 'version', version)) + FROM function_edge_logs + UNION ALL + SELECT id, identifier, timestamp, ts, event_message, message, level, level, 'postgres_logs', + metadata || jsonb_strip_nulls(jsonb_build_object('identifier', identifier, 'parsed.error_severity', error_severity)) + FROM postgres_logs + UNION ALL + SELECT id, identifier, timestamp, ts, event_message, message, level, level, 'auth_logs', + metadata || jsonb_strip_nulls(jsonb_build_object('level', level, 'status', status, 'path', path, 'msg', msg, 'error', error)) + FROM auth_logs + UNION ALL + SELECT id, identifier, timestamp, ts, event_message, message, level, level, 'storage_logs', metadata + FROM storage_logs; + +-- Enforcement of "only the unified stream is queryable" lives HERE, not in a +-- regex: the ClickHouse route's transaction runs SET LOCAL ROLE logs_reader, +-- and this role can SELECT only the logs view. The view executes with its +-- owner's privileges, so the backing tables stay readable THROUGH it while +-- direct access — however spelled (edge_logs, public.edge_logs, +-- "edge_logs") — is denied by postgres name resolution, which no regex can +-- reliably reproduce. The legacy logs.all route sets no role and keeps full +-- table access for its BigQuery-era dialect. +CREATE ROLE logs_reader; +GRANT SELECT ON logs TO logs_reader; + +-- ClickHouse numeric-cast family, as models genuinely emit it in query_logs +-- SQL (e.g. countIf(toInt32OrZero(log_attributes['status']) >= 400)). +-- These are pg reimplementations of ClickHouse BUILTINS (also not importable +-- from anywhere); signatures follow clickhouse.com/docs/sql-reference: +-- each *OrZero takes a String ONLY — no numeric overloads, so a bare +-- toInt32OrZero(42) errors here exactly as it does on hosted ClickHouse. +-- Map access always compiles to text (debugging.ts translator), which is the +-- only argument form observed from models. toString is anyelement because +-- ClickHouse's toString accepts any type. +-- Grown strictly from observed model output - see debugging.ts translator note. +CREATE FUNCTION toInt32OrZero(v text) RETURNS numeric AS $ch$ +BEGIN RETURN coalesce(v::numeric, 0); EXCEPTION WHEN others THEN RETURN 0; END +$ch$ LANGUAGE plpgsql IMMUTABLE; +CREATE FUNCTION toInt64OrZero(v text) RETURNS numeric AS $ch$ +BEGIN RETURN coalesce(v::numeric, 0); EXCEPTION WHEN others THEN RETURN 0; END +$ch$ LANGUAGE plpgsql IMMUTABLE; +CREATE FUNCTION toUInt32OrZero(v text) RETURNS numeric AS $ch$ +BEGIN RETURN coalesce(v::numeric, 0); EXCEPTION WHEN others THEN RETURN 0; END +$ch$ LANGUAGE plpgsql IMMUTABLE; +CREATE FUNCTION toString(v anyelement) RETURNS text AS $ch$ SELECT v::text $ch$ LANGUAGE sql IMMUTABLE; `; export async function seedLogRow(logsDb: PGlite, row: LogRow): Promise { @@ -125,7 +214,20 @@ export async function seedLogRow(logsDb: PGlite, row: LogRow): Promise { if (normalizedSource === 'auth') { await seedAuthLog(logsDb, log); + return; + } + + if (normalizedSource === 'storage' || normalizedSource === 'storage_logs') { + await seedStorageLog(logsDb, log); + return; } + + // Loud failure over a silent no-op: a dropped seed surfaces later as a false + // "no logs" query result, which reads as a passing scenario. Same doctrine as + // the unmodeled-source guard in debugging.ts. + throw new Error( + `unknown log seed source '${row.source}' — expected edge-function, edge, postgres/database, auth, or storage` + ); } type NormalizedLogSeed = { @@ -259,6 +361,26 @@ async function seedAuthLog( ); } +async function seedStorageLog( + logsDb: PGlite, + log: NormalizedLogSeed +): Promise { + await logsDb.query( + `INSERT INTO storage_logs + (id, identifier, timestamp, ts, event_message, message, source, level, metadata) + VALUES ($1, $2, $3, $3, $4, $4, $5, $6, $7::jsonb)`, + [ + log.id, + metadataText(log.metadata, ['identifier']), + log.ts, + log.message, + log.source, + log.level, + log.metadataJson, + ] + ); +} + function metadataText( metadata: Record, keys: string[] diff --git a/packages/platform-lite/test/clickhouse-logs.test.ts b/packages/platform-lite/test/clickhouse-logs.test.ts new file mode 100644 index 00000000..3ac45c7d --- /dev/null +++ b/packages/platform-lite/test/clickhouse-logs.test.ts @@ -0,0 +1,364 @@ +import { PGlite } from '@electric-sql/pglite'; +import { afterAll, describe, expect, it } from 'vitest'; + +import type { ProjectStore } from '../src/project-store.js'; +import { ProjectInstance } from '../src/project/ProjectInstance.js'; +import { LOGS_BASE_SQL, seedLogRow } from '../src/project/log-seeding.js'; +import { + compileClickHouseLogsSql, + createDebuggingRoutes, +} from '../src/management-api/debugging.js'; + +// Contract test for the ClickHouse-shaped /analytics/endpoints/logs fixture: +// the SQL current mcp emits (get_logs presets and query_logs-style aggregation +// over the unified 'logs' stream) must run against the local logsDb. + +const logsDb = new PGlite(); +await logsDb.exec(LOGS_BASE_SQL); +for (const [id, functionId, status] of [ + ['t1', 'stripe-webhook', 500], + ['t2', 'stripe-webhook', 500], + ['t3', 'stripe-webhook', 200], + ['t4', 'send-email', 500], + ['t5', 'send-email', 200], +] as const) { + await seedLogRow(logsDb, { + id, + ts: new Date('2026-04-28T10:00:00Z'), + source: 'edge-function', + level: status >= 500 ? 'error' : 'info', + message: 'request completed', + metadata: { function_id: functionId, status, duration_ms: 100 }, + }); +} +await seedLogRow(logsDb, { + id: 's1', + ts: new Date('2026-04-28T10:00:00Z'), + source: 'storage', + level: 'error', + message: 'upload failed: object too large', + metadata: { identifier: 'avatars-bucket' }, +}); +afterAll(() => logsDb.close()); + +// verbatim from mcp getClickHouseLogQuery('edge-function') +const EDGE_FUNCTION_PRESET = `select id, timestamp, event_message, log_attributes['response.status_code'] as status_code, log_attributes['request.method'] as method, log_attributes['function_id'] as function_id, log_attributes['execution_time_ms'] as execution_time_ms, log_attributes['deployment_id'] as deployment_id, log_attributes['version'] as version +from logs +where source = 'function_edge_logs' +order by timestamp desc +limit 100`; + +describe('compileClickHouseLogsSql + unified logs view', () => { + it('runs the mcp edge-function preset', async () => { + const result = await logsDb.query<{ + function_id: string; + status_code: unknown; + }>(compileClickHouseLogsSql(EDGE_FUNCTION_PRESET)); + expect(result.rows).toHaveLength(5); + expect(result.rows.map((r) => r.function_id).sort()).toEqual([ + 'send-email', + 'send-email', + 'stripe-webhook', + 'stripe-webhook', + 'stripe-webhook', + ]); + }); + + it('runs a query_logs-style countIf aggregation (top error function)', async () => { + const sql = `select log_attributes['function_id'] as function_id, + countIf(toInt32OrZero(log_attributes['response.status_code']) >= 500) as error_count, + count(*) as total_count + from logs + where source = 'function_edge_logs' + group by log_attributes['function_id'] + order by error_count desc + limit 5`; + const result = await logsDb.query<{ + function_id: string; + error_count: string | number; + total_count: string | number; + }>(compileClickHouseLogsSql(sql)); + expect(result.rows[0]).toMatchObject({ function_id: 'stripe-webhook' }); + expect(Number(result.rows[0]!.error_count)).toBe(2); + expect(Number(result.rows[0]!.total_count)).toBe(3); + expect(Number(result.rows[1]!.error_count)).toBe(1); + }); + + it('surfaces an error for a bare numeric comparison on a map value (hosted-faithful)', async () => { + // Hosted ClickHouse map values are String, so a bare `>= 500` comparison is + // a type error there — it must error here too. The surfaced error is the + // friction that teaches the model to wrap in toInt32OrZero (exactly what + // the verbatim fixtures below show it doing). + await expect( + logsDb.query( + compileClickHouseLogsSql( + "select countIf(log_attributes['response.status_code'] >= 500) as n from logs where source = 'function_edge_logs'" + ) + ) + ).rejects.toThrow(/operator does not exist/i); + }); + + it.each(['workflow_run_logs', 'realtime_logs'])( + 'rejects the unmodeled %s source loudly instead of returning 0 rows', + (source) => { + // No backing table exists for these preset sources; a silent empty + // result would read as "no logs" and green-light an eval the fixture + // cannot serve. The translator throws before any SQL runs. + expect(() => + compileClickHouseLogsSql( + `select id from logs where source = '${source}' limit 10` + ) + ).toThrow(/not modeled/i); + } + ); + + it('rejects direct queries against a physical table (only the unified logs stream is exposed)', () => { + // Best-effort message shaping for the common unqualified form — the model + // gets pointed at the source-filter idiom. REAL enforcement is the + // logs_reader role in the route transaction (see the route describe for + // the qualified/quoted bypass spellings this regex cannot catch). + expect(() => + compileClickHouseLogsSql( + 'select * from edge_logs order by timestamp desc limit 5' + ) + ).toThrow(/not queryable/i); + // ...while the same name stays valid as a source filter. + expect(() => + compileClickHouseLogsSql( + "select id from logs where source = 'edge_logs' limit 5" + ) + ).not.toThrow(); + }); + + it('rejects a numeric argument to toInt32OrZero (ClickHouse takes String only)', async () => { + // ClickHouse's *OrZero builtins accept String; hosted rejects + // toInt32OrZero(42), so the shim must too — only the text overload exists. + await expect( + logsDb.query( + compileClickHouseLogsSql( + "select countIf(toInt32OrZero(42) > 0) as n from logs where source = 'function_edge_logs'" + ) + ) + ).rejects.toThrow(/does not exist/i); + }); + + it('runs the mcp storage preset', async () => { + // verbatim from mcp getClickHouseLogQuery('storage'), limit interpolated to 100 + const result = await logsDb.query<{ id: string; event_message: string }>( + compileClickHouseLogsSql( + `select id, timestamp, event_message +from logs +where source = 'storage_logs' +order by timestamp desc +limit 100` + ) + ); + expect(result.rows).toHaveLength(1); + expect(result.rows[0]).toMatchObject({ + id: 's1', + event_message: 'upload failed: object too large', + }); + }); + + it('rejects an unknown seed source loudly instead of silently dropping the row', async () => { + // A silently dropped seed surfaces later as a false "no logs" query result; + // a typo'd source must fail at seed time, not read as a passing scenario. + await expect( + seedLogRow(logsDb, { + id: 'x1', + ts: new Date('2026-04-28T10:00:00Z'), + source: 'realtime', + level: 'info', + message: 'nope', + }) + ).rejects.toThrow(/unknown log seed source/i); + }); + + it('runs the runtime (function_logs) preset source', async () => { + const result = await logsDb.query<{ + function_id: string; + level: string; + severity_text: string; + }>( + compileClickHouseLogsSql( + `select id, timestamp, event_message, severity_text, log_attributes['level'] as level, log_attributes['function_id'] as function_id from logs where source = 'function_logs' order by timestamp desc limit 10` + ) + ); + expect(result.rows.map((r) => r.function_id).sort()).toEqual([ + 'send-email', + 'send-email', + 'stripe-webhook', + 'stripe-webhook', + 'stripe-webhook', + ]); + for (const row of result.rows) { + expect(['error', 'info']).toContain(row.level); + expect(row.severity_text).toBe(row.level); + } + }); + + // Both cases are verbatim model output from the PR-333 A/B + // (results-ab/investigate-logs-001-top-error-function.treatment.json): + // frozen regression fixtures, one per ClickHouse builtin shim they exercise. + it.each([ + [ + 'toInt32OrZero', + `select log_attributes['function_id'] as function_id, + count(*) as total_events, + countIf(level = 'error' or toInt32OrZero(log_attributes['status']) >= 400) as error_count +from logs +where source = 'function_edge_logs' +group by function_id +order by error_count desc`, + ], + [ + 'toString nested inside toInt32OrZero (rerun call 4)', + `select log_attributes['function_id'] as function_id, count(*) as total_events, countIf(toInt32OrZero(toString(log_attributes['status'])) >= 400 or level = 'error') as error_count from logs where source = 'function_edge_logs' group by function_id order by error_count desc`, + ], + ])( + 'runs the exact ClickHouse SQL claude-sonnet-5 emitted in the PR-333 A/B (%s)', + async (_label, sql) => { + const result = await logsDb.query<{ + function_id: string; + error_count: unknown; + }>(compileClickHouseLogsSql(sql)); + expect(result.rows[0]).toMatchObject({ function_id: 'stripe-webhook' }); + expect(Number(result.rows[0]!.error_count)).toBe(2); + } + ); +}); + +// Route-level contract: the read-only guarantee must hold at the HTTP boundary +// where model-authored SQL arrives, not just in the translator. A real +// ProjectInstance (constructor is init-free; the route only touches logsDb) in +// a real Map — the exact ProjectStore shape, no casts. Dedicated instance so a +// failing guard can't poison the other tests' fixture rows. +describe('/v1/projects/:ref/analytics/endpoints/logs route', () => { + const project = new ProjectInstance('proj', 'proj', 'test-org'); + const routeDb = project.logsDb; + const ready = (async () => { + await routeDb.exec(LOGS_BASE_SQL); + for (const id of ['r1', 'r2']) { + await seedLogRow(routeDb, { + id, + ts: new Date('2026-04-28T10:00:00Z'), + source: 'edge-function', + level: 'error', + message: 'boom', + metadata: { + function_id: 'stripe-webhook', + status: 500, + duration_ms: 100, + }, + }); + } + })(); + afterAll(() => project.close()); + + const store: ProjectStore = new Map([['proj', project]]); + const { app } = createDebuggingRoutes(store); + const url = (sql: string) => + `/v1/projects/proj/analytics/endpoints/logs?sql=${encodeURIComponent(sql)}`; + const countRows = async () => + Number( + ( + await routeDb.query<{ n: string }>( + 'select count(*) as n from function_edge_logs' + ) + ).rows[0]!.n + ); + + it('serves a ClickHouse query with the {result} response shape', async () => { + await ready; + const res = await app.request( + url( + "select id, log_attributes['function_id'] as function_id from logs where source = 'function_edge_logs' order by timestamp desc limit 10" + ) + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { result: unknown[] }; + expect(body.result).toHaveLength(2); + }); + + it('rejects a data-modifying CTE and leaves fixture rows intact', async () => { + await ready; + const before = await countRows(); + // This write passes the `with` prefix gate AND the relation guard (INSERT + // INTO is not a from/join position), so the ONLY thing stopping it is the + // read-only transaction — the last line of defense this test pins. (A CTE + // DELETE ... FROM a physical table is now caught earlier, by the + // unified-stream relation guard.) + const res = await app.request( + url( + "WITH x AS (INSERT INTO function_edge_logs (id) VALUES ('evil') RETURNING id) SELECT * FROM x" + ) + ); + // Pin the status: 200 proves this went through the SQL-error path (the + // read-only transaction), not the prefix gate's 400 — the gate's message + // also matches /read-only/i, so without this a refactor unifying the two + // rejection paths would leave the transaction guard silently untested. + expect(res.status).toBe(200); + const body = (await res.json()) as { result: unknown[]; error?: string }; + expect(body.error).toMatch(/read-only/i); + expect(body.result).toEqual([]); + expect(await countRows()).toBe(before); + }); + + it('rejects plain non-SELECT statements at the prefix gate', async () => { + await ready; + const before = await countRows(); + const res = await app.request(url('DELETE FROM function_edge_logs')); + expect(res.status).toBe(400); + // mcp's assertSuccess parses non-2xx bodies as {message} — pin that key so + // the informative text reaches the model instead of the generic fallback. + const body = (await res.json()) as { + result: unknown[]; + error?: string; + message?: string; + }; + expect(body.message).toMatch(/read-only SELECT/i); + expect(body.error).toBe(body.message); + expect(body.result).toEqual([]); + expect(await countRows()).toBe(before); + }); + + it('surfaces the unmodeled-source error through the HTTP boundary', async () => { + await ready; + const res = await app.request( + url("select id from logs where source = 'realtime_logs' limit 10") + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { result: unknown[]; error?: string }; + expect(body.error).toMatch(/not modeled/i); + expect(body.result).toEqual([]); + }); + + it.each([ + ['schema-qualified', 'select * from public.function_edge_logs limit 5'], + ['quoted', 'select * from "function_edge_logs" limit 5'], + ])( + 'denies %s access to a backing table via the logs_reader role', + async (_label, sql) => { + await ready; + // These spellings bypass the best-effort regex in compileClickHouseLogsSql; + // postgres name resolution under SET LOCAL ROLE logs_reader is what + // actually enforces the unified-stream-only contract. + const res = await app.request(url(sql)); + expect(res.status).toBe(200); + const body = (await res.json()) as { result: unknown[]; error?: string }; + expect(body.error).toMatch(/permission denied/i); + expect(body.result).toEqual([]); + } + ); + + it('still serves the unified logs view under the logs_reader role', async () => { + await ready; + const res = await app.request( + url( + "select id from logs where source = 'function_edge_logs' order by timestamp desc limit 10" + ) + ); + const body = (await res.json()) as { result: unknown[] }; + expect(body.result).toHaveLength(2); + }); +}); diff --git a/packages/platform-lite/test/openapi.test.ts b/packages/platform-lite/test/openapi.test.ts index 56b48f14..8d226df1 100644 --- a/packages/platform-lite/test/openapi.test.ts +++ b/packages/platform-lite/test/openapi.test.ts @@ -8,7 +8,7 @@ describe('OpenAPI surface', () => { expect(res.status).toBe(200); const spec = (await res.json()) as { - paths: Record>; + paths: Record>; }; expect(spec.paths['/v1/projects']).toHaveProperty('get'); @@ -25,11 +25,29 @@ describe('OpenAPI surface', () => { expect( spec.paths['/v1/projects/{ref}/database/migrations'] ).not.toHaveProperty('delete'); + // Distinct operations for the legacy and ClickHouse logs endpoints — + // upstream renamed the legacy one when the no-`.all` route landed. + expect( + spec.paths['/v1/projects/{ref}/analytics/endpoints/logs.all']!.get! + .operationId + ).toBe('v1-get-project-logs-all'); + expect( + spec.paths['/v1/projects/{ref}/analytics/endpoints/logs']!.get! + .operationId + ).toBe('v1-get-project-logs'); expect( spec.paths['/v1/projects/{ref}/functions/{function_slug}'] ).toHaveProperty('get'); expect( spec.paths['/v1/projects/{ref}/functions/{function_slug}/body'] ).toHaveProperty('get'); + + // operationIds must be unique across the advertised spec (OpenAPI + // requirement; a duplicate breaks generated clients). + const ids = Object.values(spec.paths) + .flatMap((methods) => Object.values(methods)) + .map((op) => op.operationId) + .filter((id): id is string => typeof id === 'string'); + expect(new Set(ids).size).toBe(ids.length); }); });