Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 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
7d84ccf
platform-lite: fix logs fixture divergences found in review
barryroodt Aug 4, 2026
d0c6b81
platform-lite: fire the wall-clock guard only on logs-reading SQL
barryroodt Aug 4, 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
212 changes: 212 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,63 @@ 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. This 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.
//
// Because it is only message shaping, it must not 400 SQL that hosted
// accepts, or the model is failed by a fixture artifact and burns turns
// rewriting valid SQL. Both tests read the blanked form, so a leading `--`
// explanation (models write them) becomes whitespace the prefix test skips,
// and `like '%;%'` is not read as a second statement. A leading paren is
// tolerated too. postgres still sees the ORIGINAL sql.
const stmt = sql.trim().replace(/;+\s*$/, '');
const code = blankNonCode(stmt);
if (code.includes(';') || !/^[\s(]*(?:select|with)\b/i.test(code)) {
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 +141,161 @@ 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The KNOWN LIMITATION note covers the query params, but the same time-semantics hole is reachable through the SQL itself, where it fails silently instead of loudly.

For example, seeds carry fixed past dates while now() is real wall-clock, so select count(*) from logs where source = 'edge_logs' and timestamp > now() - interval '24 hours' returns 200 {result: []} with no error, and the agent concludes "no errors occurred".

Until relative-time seeding lands, a guard that rejects now()/current_timestamp in logs SQL (loud, like the other guards) would keep evals honest.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, timestamp > now() - interval '24 hours' returns 200 {result: []}. Wall-clock functions now reject loudly (7d84ccf), but only when the statement reads logs, so the now() orientation probes your #333 A/B saw still pass (d0c6b81).

* (fixed-date seeds), so window-correctness of model queries is NOT exercised
* locally. A time-window-discriminating eval needs relative-time seeding.
* The same hole is reachable through the SQL itself, where it used to fail
* SILENTLY: seeds carry fixed past dates while now() is real wall-clock, so
* `where timestamp > now() - interval '24 hours'` returned 200 {result: []} and
* the model concluded "no errors occurred". Wall-clock functions therefore
* reject loudly, the same doctrine as the unmodeled sources. Drop the guard
* once relative-time seeding lands.
*
* The guard fires only on a statement that READS 'logs'. A bare `select now()`
* is an orientation probe: it touches no seeded row, so it cannot report a
* false "no logs", and models were observed opening with exactly that before
* building a window (the mcp#333 A/B). Rejecting the probe would cost a turn
* and teach nothing; rejecting the FILTER is the part that matters.
*/
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;
const WALL_CLOCK_FNS =
/\b(now|now64|today|yesterday|current_timestamp|current_date|localtimestamp)\b/i;
// 'logs' as a relation; log_attributes and other log_* identifiers do not match.
const READS_LOGS = /\blogs\b/i;

/**
* Blank everything that is not executable SQL — single-quoted literals, `--`
* line comments and block comments — preserving length and newlines. Syntax
* guards read this instead of the raw sql so payload and prose never trip them:
* `like '%;%'` is not a second statement, and `-- as of now` is not a
* wall-clock read. Doubled quotes are the SQL escape and stay in the literal.
*/
export function blankNonCode(sql: string): string {
let out = '';
let inLiteral = false;
for (let i = 0; i < sql.length; i += 1) {
const ch = sql[i]!;
if (inLiteral) {
if (ch === "'") {
if (sql[i + 1] === "'") {
out += ' ';
i += 1;
continue;
}
inLiteral = false;
out += ch;
continue;
}
out += ch === '\n' ? ch : ' ';
continue;
}
if (ch === "'") {
inLiteral = true;
out += ch;
continue;
}
if (ch === '-' && sql[i + 1] === '-') {
while (i < sql.length && sql[i] !== '\n') {
out += ' ';
i += 1;
}
if (i < sql.length) out += '\n';
continue;
}
if (ch === '/' && sql[i + 1] === '*') {
const end = sql.indexOf('*/', i + 2);
const stop = end === -1 ? sql.length : end + 2;
for (; i < stop; i += 1) out += sql[i] === '\n' ? '\n' : ' ';
i -= 1;
continue;
}
out += ch;
}
return out;
}

export function compileClickHouseLogsSql(sql: string): string {
// Source names arrive as string literals (where source = 'realtime_logs'), so
// this guard reads the RAW sql on purpose — blanking literals would hide it.
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]}'`
);
}
// Literals and comments blanked: `event_message like '%now()%'` is payload and
// `-- as of now` is prose, neither is a wall-clock read. Only a statement that
// reads 'logs' can report a false empty, so a bare `select now()` probe is let
// through.
const code = blankNonCode(sql);
const wallClock = READS_LOGS.test(code) ? WALL_CLOCK_FNS.exec(code) : null;
if (wallClock) {
throw new Error(
`'${wallClock[1]}' is not supported against platform-lite log seeds — seeds carry fixed past timestamps, so a wall-clock window silently matches 0 rows; drop the time filter, or use an absolute range covering the seeded dates (select min(timestamp), max(timestamp) from logs)`
);
}
return sql
.replace(
// Whitespace-tolerant: log_attributes[ 'k' ] would otherwise fall through
// to pg jsonb subscripting, which stays jsonb and renders quote-wrapped
// ("stripe-webhook"), so an equality against a text literal throws
// "invalid input syntax for type json".
/\blog_attributes\[\s*'([^']+)'\s*\]/gi,
// coalesce to '' because a ClickHouse Map returns '' for an absent key
// while ->> returns NULL: without it `log_attributes['error'] = ''`
// matches every row hosted and nothing here.
(_m, key: string) => `coalesce(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
Loading