Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
dcf6935
platform-lite: serve the ClickHouse logs endpoint (/analytics/endpoin…
barryroodt Jul 21, 2026
15af251
platform-lite: ClickHouse toIntOrZero family for query_logs SQL
barryroodt Jul 21, 2026
c60adeb
platform-lite: polymorphic toString for ClickHouse-dialect logs SQL
barryroodt Jul 21, 2026
2e3d2b3
platform-lite: document the deliberate ClickHouse-compat surface + ti…
barryroodt Jul 21, 2026
d548d65
platform-lite: route-level tests for the logs read-only guarantee
barryroodt Jul 21, 2026
a0305f8
platform-lite: document provenance of the hand-modeled ClickHouse sur…
barryroodt Jul 21, 2026
694bcc0
platform-lite: correct provenance conflation + logsServiceSchema drif…
barryroodt Jul 21, 2026
8f71f95
platform-lite: drop the logsServiceSchema tripwire, keep corrected pr…
barryroodt Jul 21, 2026
32f9ce2
platform-lite: cite both platform PRs, note in-review status
barryroodt Jul 21, 2026
a52cfab
platform-lite: separate per-PR platform capabilities in provenance
barryroodt Jul 21, 2026
864f488
platform-lite: disambiguate current-main vs mcp#333 in the view header
barryroodt Jul 21, 2026
b759ca2
platform-lite: review fixes — {message} on 400, pin CTE status, reloc…
barryroodt Jul 23, 2026
51dcf92
platform-lite: hosted-faithful map values + review minors
barryroodt Jul 23, 2026
89b814d
platform-lite: close out review minors properly
barryroodt Jul 23, 2026
36e6357
platform-lite: seed storage logs; reject unknown seed sources loudly
barryroodt Jul 23, 2026
c3dfccf
platform-lite: expose only the unified logs stream; String-only OrZer…
barryroodt Jul 23, 2026
8cc63e4
chore: merge main (repo-wide biome formatting)
barryroodt Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions packages/platform-lite/src/management-api/debugging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Comment thread
barryroodt marked this conversation as resolved.
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<Record<string, unknown>>(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);
Expand Down Expand Up @@ -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();

Expand Down
101 changes: 100 additions & 1 deletion packages/platform-lite/src/management-api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
83 changes: 83 additions & 0 deletions packages/platform-lite/src/management-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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?: {
Expand Down Expand Up @@ -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: {
Expand Down
Loading