Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
9 changes: 8 additions & 1 deletion web/admin/app/api/omi/releases/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { verifyAdmin } from "@/lib/auth";
import { withRowLimit } from "@/lib/posthog";
import {
desktopQualificationFromMetadata,
desktopReleaseLifecycle,
Expand Down Expand Up @@ -106,7 +107,13 @@ async function posthogQuery(query: string): Promise<any> {
Authorization: `Bearer ${POSTHOG_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query: { kind: "HogQLQuery", query } }),
// This route has its own fetch (bypasses lib/posthog's posthogFetch), so
// it applies the same shared row-limit guard directly — the version×event
// query has no LIMIT and 5 events × ~40-80 versions exceeds the silent
// default 100, dropping per-version counts to 0 (#10190).
body: JSON.stringify({
query: { kind: "HogQLQuery", query: withRowLimit(query) },
}),
cache: "no-store",
},
);
Expand Down
46 changes: 46 additions & 0 deletions web/admin/lib/__tests__/posthog-row-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";

import { POSTHOG_SERVED_MAX_ROWS, withRowLimit } from "../posthog";

// #10190: PostHog silently fills LIMIT 100 into any HogQL (sub)query without
// one. withRowLimit binds one explicit outer LIMIT so the default cap can never
// truncate, and wraps in a subquery so a UNION's total (not just its last arm)
// is bounded.
describe("withRowLimit", () => {
it("binds an explicit outer LIMIT at the served max by default", () => {
const out = withRowLimit(
"SELECT version, count() FROM events GROUP BY version",
);
expect(out).toContain(`LIMIT ${POSTHOG_SERVED_MAX_ROWS}`);
// The served max is 50k, verified live (LIMIT 100000 returns 50000).
expect(POSTHOG_SERVED_MAX_ROWS).toBe(50_000);
});

it("wraps in a subquery so the LIMIT binds the whole UNION, not the last arm", () => {
const union = "SELECT 1 AS a UNION ALL SELECT 2 AS a";
const out = withRowLimit(union);
// The union must sit inside a subquery; the LIMIT must come after the close
// paren, not inline with the last arm (which would bind that arm only).
expect(out).toMatch(
/SELECT \* FROM \(\s*[\s\S]*UNION ALL[\s\S]*\)\s*AS _row_limit_guard\s*LIMIT/,
);
const limitPos = out.lastIndexOf("LIMIT");
const closeParenPos = out.lastIndexOf(")");
expect(limitPos).toBeGreaterThan(closeParenPos);
});

it("is ceiling-only: a caller's tighter inner LIMIT is preserved inside the wrap", () => {
const out = withRowLimit("SELECT * FROM events LIMIT 10");
// The inner LIMIT 10 survives (inside the subquery); the outer is just a cap.
expect(out).toContain("LIMIT 10");
expect(out).toContain(`LIMIT ${POSTHOG_SERVED_MAX_ROWS}`);
// Inner limit appears before the guard's outer limit.
expect(out.indexOf("LIMIT 10")).toBeLessThan(
out.lastIndexOf(`LIMIT ${POSTHOG_SERVED_MAX_ROWS}`),
);
});

it("honors a custom max", () => {
expect(withRowLimit("SELECT 1", 500)).toContain("LIMIT 500");
});
});
36 changes: 30 additions & 6 deletions web/admin/lib/posthog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@ import { getDb } from "@/lib/firebase/admin";
const CACHE_COLLECTION = "admin_stats_cache";
const SOFT_TTL_MS = 30 * 60 * 1000; // serve cached without re-querying for 30 min

// PostHog's query API fills `LIMIT 100` into any HogQL (sub)query that carries
// none and truncates silently — this cut the newest days off the
// response-reliability charts (#10191) and drops per-version rows on the
// releases panel (#10190). 50_000 is PostHog's served maximum (verified live:
// `LIMIT 100000` returns exactly 50000 rows).
export const POSTHOG_SERVED_MAX_ROWS = 50_000;

// Guard against the silent default cap by binding one explicit outer LIMIT to
// the whole result. Wrapping in a subquery is required for correctness with
// `UNION ALL`, where a trailing `LIMIT` binds to the last arm only (verified:
// `SELECT 1 UNION ALL SELECT 2 LIMIT 1` returns 2 rows). Wrapping only ever
// adds a ceiling: a caller's tighter inner `LIMIT` still wins, so this never
// widens an intentionally small query.
export function withRowLimit(
query: string,
max: number = POSTHOG_SERVED_MAX_ROWS,
): string {
return `SELECT * FROM (\n${query}\n) AS _row_limit_guard\nLIMIT ${max}`;
}

type CacheDoc = { payload: string; freshAt: number };

async function readCache(
Expand Down Expand Up @@ -67,7 +87,9 @@ export async function posthogFetch(
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ query: { kind: "HogQLQuery", query } }),
body: JSON.stringify({
query: { kind: "HogQLQuery", query: withRowLimit(query) },
}),
});
if (res.status !== 429 || attempt === maxRetries) return res;
const waitMs = 800 * (attempt + 1) + Math.floor(Math.random() * 300);
Expand Down Expand Up @@ -146,13 +168,15 @@ export async function posthogResults(
}
const raw = await res.json();
const results = Array.isArray(raw.results) ? raw.results : [];
// PostHog fills LIMIT 100 into any HogQL (sub)query that has none and
// truncates silently — exactly 100 rows from a LIMIT-less query is the
// signature of that cap (it cut the newest days off response-reliability).
if (results.length === 100 && !/\blimit\b/i.test(query)) {
// posthogFetch now binds an explicit LIMIT of POSTHOG_SERVED_MAX_ROWS to
// every query, so the silent default-100 cap can no longer truncate. The
// remaining ceiling is PostHog's served maximum: a result at that count is
// itself truncated and the caller should widen its window or paginate.
if (results.length >= POSTHOG_SERVED_MAX_ROWS) {
console.warn(
"PostHog returned exactly 100 rows for a query without LIMIT — likely truncated by the default cap",
"PostHog returned the served-max row count — result is truncated at the ceiling",
{
servedMax: POSTHOG_SERVED_MAX_ROWS,
querySnippet: query.replace(/\s+/g, " ").trim().slice(0, 160),
},
);
Expand Down
Loading