diff --git a/apps/local-ui/package.json b/apps/local-ui/package.json
index 1c28ebcda..c7ce76b4a 100644
--- a/apps/local-ui/package.json
+++ b/apps/local-ui/package.json
@@ -23,9 +23,11 @@
},
"devDependencies": {
"@tailwindcss/vite": "catalog:tailwind",
+ "@testing-library/react": "^16.3.2",
"@types/react": "catalog:react",
"@types/react-dom": "catalog:react",
"@vitejs/plugin-react": "^6.0.2",
+ "jsdom": "^29.1.1",
"tailwindcss": "catalog:tailwind",
"tw-animate-css": "catalog:tailwind",
"typescript": "catalog:tooling",
diff --git a/apps/local-ui/src/components/toolbar.tsx b/apps/local-ui/src/components/toolbar.tsx
index e756b7c29..8558ea2c9 100644
--- a/apps/local-ui/src/components/toolbar.tsx
+++ b/apps/local-ui/src/components/toolbar.tsx
@@ -17,9 +17,18 @@ export { Toolbar, ToolbarSearch, ToolbarStat, ToolbarStats } from "@maple/ui/com
* so invalidating that prefix refetches exactly the mounted view's queries
* (list + facets) — React Query only refetches active observers.
*/
-export function RefreshButton({ className }: { className?: string }) {
+export function RefreshButton({
+ className,
+ onBeforeRefresh,
+}: {
+ className?: string
+ onBeforeRefresh?: () => void
+}) {
const queryClient = useQueryClient()
- const onRefresh = useCallback(() => queryClient.invalidateQueries({ queryKey: ["local"] }), [queryClient])
+ const onRefresh = useCallback(() => {
+ onBeforeRefresh?.()
+ return queryClient.invalidateQueries({ queryKey: ["local"] })
+ }, [onBeforeRefresh, queryClient])
return
}
diff --git a/apps/local-ui/src/hooks/use-local-log-services.test.ts b/apps/local-ui/src/hooks/use-local-log-services.test.ts
new file mode 100644
index 000000000..da5eefd91
--- /dev/null
+++ b/apps/local-ui/src/hooks/use-local-log-services.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest"
+import { compileLocalLogServicesQuery } from "./use-local-log-services"
+
+describe("compileLocalLogServicesQuery", () => {
+ it("builds the Logs service filter from log-producing services", () => {
+ const { sql } = compileLocalLogServicesQuery("2026-07-30 13:05:00", "2026-07-30 14:05:00")
+
+ expect(sql).toContain("FROM logs")
+ expect(sql).toContain("ServiceName AS name")
+ expect(sql).toContain("TimestampTime >= '2026-07-30 13:05:00'")
+ expect(sql).toContain("TimestampTime <= '2026-07-30 14:05:00'")
+ expect(sql).toContain("Timestamp >= '2026-07-30 13:05:00'")
+ expect(sql).toContain("Timestamp <= '2026-07-30 14:05:00'")
+ expect(sql).toContain("GROUP BY name")
+ expect(sql).not.toContain("LIMIT")
+ expect(sql).not.toContain("UNION ALL")
+ expect(sql).not.toContain("logs_aggregates_hourly")
+ })
+
+ it("cannot offer a stale service that exists only in the hourly aggregate", () => {
+ const { sql } = compileLocalLogServicesQuery("2026-07-01 00:00:00", "2026-07-30 23:59:59")
+
+ expect(sql).toContain("FROM logs")
+ expect(sql).not.toContain("logs_aggregates_hourly")
+ })
+})
diff --git a/apps/local-ui/src/hooks/use-local-log-services.ts b/apps/local-ui/src/hooks/use-local-log-services.ts
new file mode 100644
index 000000000..23bf7589a
--- /dev/null
+++ b/apps/local-ui/src/hooks/use-local-log-services.ts
@@ -0,0 +1,32 @@
+import { useQuery } from "@tanstack/react-query"
+import { CH } from "@maple/query-engine"
+import { executeLocalCompiledQuery } from "@/lib/query"
+import { LOCAL_ORG_ID } from "../lib/constants"
+import type { TimeBounds } from "../lib/time"
+import type { FilterOption } from "@maple/ui/components/filters/filter-section"
+
+/**
+ * Distinct services that emitted logs in the selected window. This deliberately
+ * scans the same raw table and exact timestamp bounds as the rendered list, so
+ * neither partial hourly buckets nor longer-lived aggregates can add or hide a
+ * service option.
+ */
+export function compileLocalLogServicesQuery(startTime: string, endTime: string) {
+ return CH.compile(CH.logsBreakdownQuery({ groupBy: "service", limit: null, source: "raw" }), {
+ orgId: LOCAL_ORG_ID,
+ startTime,
+ endTime,
+ })
+}
+
+export function useLocalLogServices(bounds: TimeBounds) {
+ return useQuery>({
+ queryKey: ["local", "logs", "services", bounds.startTime, bounds.endTime],
+ staleTime: 60_000,
+ queryFn: async () => {
+ const compiled = compileLocalLogServicesQuery(bounds.startTime, bounds.endTime)
+ const rows = await executeLocalCompiledQuery(compiled)
+ return rows.filter((row) => row.name).map((row) => ({ name: row.name, count: Number(row.count) }))
+ },
+ })
+}
diff --git a/apps/local-ui/src/hooks/use-local-logs.ts b/apps/local-ui/src/hooks/use-local-logs.ts
index 30e017061..f15f7db52 100644
--- a/apps/local-ui/src/hooks/use-local-logs.ts
+++ b/apps/local-ui/src/hooks/use-local-logs.ts
@@ -2,7 +2,7 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
import { CH } from "@maple/query-engine"
import { executeLocalCompiledQuery } from "@/lib/query"
import { LOCAL_ORG_ID } from "../lib/constants"
-import { boundsForRange } from "../lib/time"
+import type { TimeBounds } from "../lib/time"
import type { FilterOption } from "@maple/ui/components/filters/filter-section"
const PAGE_SIZE = 50
@@ -14,20 +14,17 @@ export interface LogFilters {
severity?: string
/** Substring match on the log body. */
search?: string
- /** Time-range preset key (see `TIME_RANGES`). */
- range?: string
}
/**
* Infinite log stream, newest first. Keyset pagination on `Timestamp` — the
* cursor is the last row's `timestamp`.
*/
-export function useLocalLogs(filters: LogFilters) {
+export function useLocalLogs(filters: LogFilters, bounds: TimeBounds) {
return useInfiniteQuery({
- queryKey: ["local", "logs", filters],
+ queryKey: ["local", "logs", filters, bounds.startTime, bounds.endTime],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) => {
- const { startTime, endTime } = boundsForRange(filters.range)
const compiled = CH.compile(
CH.logsListQuery({
limit: PAGE_SIZE,
@@ -36,7 +33,7 @@ export function useLocalLogs(filters: LogFilters) {
severity: filters.severity,
search: filters.search,
}),
- { orgId: LOCAL_ORG_ID, startTime, endTime },
+ { orgId: LOCAL_ORG_ID, ...bounds },
)
return executeLocalCompiledQuery(compiled)
},
@@ -49,16 +46,14 @@ export function useLocalLogs(filters: LogFilters) {
* Distinct severity values in the window (with counts), for the severity facet.
* Derived from the data so the option casing always matches what's stored.
*/
-export function useLocalLogSeverities(range: string | undefined) {
+export function useLocalLogSeverities(bounds: TimeBounds) {
return useQuery>({
- queryKey: ["local", "log-severities", range],
+ queryKey: ["local", "log-severities", bounds.startTime, bounds.endTime],
staleTime: 60_000,
queryFn: async () => {
- const { startTime, endTime } = boundsForRange(range)
const compiled = CH.compile(CH.logsBreakdownQuery({ groupBy: "severity", limit: 20 }), {
orgId: LOCAL_ORG_ID,
- startTime,
- endTime,
+ ...bounds,
})
const rows = await executeLocalCompiledQuery(compiled)
return rows.filter((row) => row.name).map((row) => ({ name: row.name, count: row.count }))
diff --git a/apps/local-ui/src/hooks/use-local-services.ts b/apps/local-ui/src/hooks/use-local-services.ts
deleted file mode 100644
index d9d130762..000000000
--- a/apps/local-ui/src/hooks/use-local-services.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { useQuery } from "@tanstack/react-query"
-import { CH } from "@maple/query-engine"
-import { executeLocalCompiledQuery } from "@/lib/query"
-import { LOCAL_ORG_ID } from "../lib/constants"
-import { boundsForRange } from "../lib/time"
-import type { FilterOption } from "@maple/ui/components/filters/filter-section"
-
-/**
- * Distinct service names in the window, shaped as filter options. Drives the
- * "Service" facet on the Traces and Logs sidebars. Backed by the
- * service_overview MV scan, so it's cheap to refresh alongside the list.
- */
-export function useLocalServices(range: string | undefined) {
- return useQuery>({
- queryKey: ["local", "services", range],
- staleTime: 60_000,
- queryFn: async () => {
- const { startTime, endTime } = boundsForRange(range)
- const compiled = CH.compileUnion(CH.servicesFacetsQuery(), {
- orgId: LOCAL_ORG_ID,
- startTime,
- endTime,
- })
- const rows = await executeLocalCompiledQuery(compiled)
- return rows
- .filter((row) => row.facetType === "service" && row.name)
- .map((row) => ({ name: row.name, count: row.count }))
- },
- })
-}
diff --git a/apps/local-ui/src/hooks/use-log-time-window.test.ts b/apps/local-ui/src/hooks/use-log-time-window.test.ts
new file mode 100644
index 000000000..abc997143
--- /dev/null
+++ b/apps/local-ui/src/hooks/use-log-time-window.test.ts
@@ -0,0 +1,30 @@
+// @vitest-environment jsdom
+
+import { act, renderHook } from "@testing-library/react"
+import { afterEach, describe, expect, it, vi } from "vitest"
+import { useLogTimeWindow } from "./use-log-time-window"
+
+describe("useLogTimeWindow", () => {
+ afterEach(() => vi.useRealTimers())
+
+ it("advances one shared window when a log filter changes later", () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date("2026-07-30T14:05:00Z"))
+ const { result, rerender } = renderHook(
+ ({ range, severity }) => ({ severity, ...useLogTimeWindow(range) }),
+ { initialProps: { range: "1h", severity: undefined as string | undefined } },
+ )
+
+ expect(result.current.bounds.startTime).toBe("2026-07-30 13:05:00")
+
+ vi.setSystemTime(new Date("2026-07-30T16:05:00Z"))
+ act(() => result.current.advance())
+ rerender({ range: "1h", severity: "ERROR" })
+
+ expect(result.current.severity).toBe("ERROR")
+ expect(result.current.bounds).toEqual({
+ startTime: "2026-07-30 15:05:00",
+ endTime: "2026-07-30 17:05:00",
+ })
+ })
+})
diff --git a/apps/local-ui/src/hooks/use-log-time-window.ts b/apps/local-ui/src/hooks/use-log-time-window.ts
new file mode 100644
index 000000000..22748e02d
--- /dev/null
+++ b/apps/local-ui/src/hooks/use-log-time-window.ts
@@ -0,0 +1,11 @@
+import { useCallback, useMemo, useState } from "react"
+import { boundsForRange } from "../lib/time"
+
+/** One page-owned time window shared by the log list and all of its facets. */
+export function useLogTimeWindow(range: string | undefined) {
+ const [anchorMs, setAnchorMs] = useState(() => Date.now())
+ const bounds = useMemo(() => boundsForRange(range, anchorMs), [anchorMs, range])
+ const advance = useCallback(() => setAnchorMs(Date.now()), [])
+
+ return { bounds, advance }
+}
diff --git a/apps/local-ui/src/lib/time.test.ts b/apps/local-ui/src/lib/time.test.ts
new file mode 100644
index 000000000..10bd3e0a2
--- /dev/null
+++ b/apps/local-ui/src/lib/time.test.ts
@@ -0,0 +1,24 @@
+import { afterEach, describe, expect, it, vi } from "vitest"
+import { boundsForRange } from "./time"
+
+describe("boundsForRange", () => {
+ afterEach(() => vi.useRealTimers())
+
+ it("can advance every consumer from one explicit page anchor", () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date("2026-07-30T14:05:00Z"))
+ const initial = boundsForRange("1h", Date.now())
+
+ vi.setSystemTime(new Date("2026-07-30T16:05:00Z"))
+ const advanced = boundsForRange("1h", Date.now())
+
+ expect(initial).toEqual({
+ startTime: "2026-07-30 13:05:00",
+ endTime: "2026-07-30 15:05:00",
+ })
+ expect(advanced).toEqual({
+ startTime: "2026-07-30 15:05:00",
+ endTime: "2026-07-30 17:05:00",
+ })
+ })
+})
diff --git a/apps/local-ui/src/lib/time.ts b/apps/local-ui/src/lib/time.ts
index 8f25f922f..819967d81 100644
--- a/apps/local-ui/src/lib/time.ts
+++ b/apps/local-ui/src/lib/time.ts
@@ -36,12 +36,11 @@ export const TIME_RANGES: ReadonlyArray = [
export const DEFAULT_RANGE = "30d"
/** Resolve a range key to ClickHouse DateTime bounds, padding the upper bound for clock skew. */
-export function boundsForRange(key: string | undefined): TimeBounds {
+export function boundsForRange(key: string | undefined, anchorMs = Date.now()): TimeBounds {
const range = TIME_RANGES.find((r) => r.key === key) ?? TIME_RANGES[TIME_RANGES.length - 1]
- const now = Date.now()
return {
- startTime: toClickHouseDateTime(now - range.minutes * 60 * 1000),
- endTime: toClickHouseDateTime(now + 60 * 60 * 1000),
+ startTime: toClickHouseDateTime(anchorMs - range.minutes * 60 * 1000),
+ endTime: toClickHouseDateTime(anchorMs + 60 * 60 * 1000),
}
}
diff --git a/apps/local-ui/src/views/logs-view.tsx b/apps/local-ui/src/views/logs-view.tsx
index 55becd46b..30209e923 100644
--- a/apps/local-ui/src/views/logs-view.tsx
+++ b/apps/local-ui/src/views/logs-view.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef, useState } from "react"
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import { LogAttributeChip } from "@maple/ui/components/logs/log-attribute-chip"
import { CodeIcon } from "@maple/ui/components/icons"
@@ -6,7 +6,8 @@ import { Spinner } from "@maple/ui/components/ui/spinner"
import { pickImportantAttributes } from "@maple/ui/lib/log-attributes"
import { getSeverityColor } from "@maple/ui/lib/severity"
import { useLocalLogs, useLocalLogSeverities } from "../hooks/use-local-logs"
-import { useLocalServices } from "../hooks/use-local-services"
+import { useLocalLogServices } from "../hooks/use-local-log-services"
+import { useLogTimeWindow } from "../hooks/use-log-time-window"
import { useQueryParams } from "../lib/router"
import { DEFAULT_RANGE } from "../lib/time"
import { normalizeLog, type LocalLog } from "../lib/log-shape"
@@ -37,11 +38,19 @@ export function LogsView() {
const service = query.get("service") || undefined
const severity = query.get("severity") || undefined
const search = query.get("q") || undefined
+ const timeWindow = useLogTimeWindow(range)
+ const updateParamsWithFreshTimeWindow = useCallback(
+ (updates: Record) => {
+ timeWindow.advance()
+ setParams(updates)
+ },
+ [setParams, timeWindow.advance],
+ )
- const services = useLocalServices(range)
- const severities = useLocalLogSeverities(range)
+ const services = useLocalLogServices(timeWindow.bounds)
+ const severities = useLocalLogSeverities(timeWindow.bounds)
const { data, isPending, isError, error, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } =
- useLocalLogs({ service, severity, search, range })
+ useLocalLogs({ service, severity, search }, timeWindow.bounds)
const rows = useMemo>(() => (data?.pages.flat() ?? []).map(normalizeLog), [data])
const scrollRef = useRef(null)
@@ -79,20 +88,20 @@ export function LogsView() {
>
setParams({ service: null, severity: null })}
+ onClear={() => updateParamsWithFreshTimeWindow({ service: null, severity: null })}
/>
({ name: o.name, count: o.count }))}
selected={severity ? [severity] : []}
- onChange={(vals) => setParams({ severity: vals.at(-1) ?? null })}
+ onChange={(vals) => updateParamsWithFreshTimeWindow({ severity: vals.at(-1) ?? null })}
/>
({ name: o.name, count: o.count }))}
selected={service ? [service] : []}
- onChange={(vals) => setParams({ service: vals.at(-1) ?? null })}
+ onChange={(vals) => updateParamsWithFreshTimeWindow({ service: vals.at(-1) ?? null })}
/>
@@ -102,13 +111,16 @@ export function LogsView() {
setParams({ q: value ?? null })}
+ onSearch={(value) => updateParamsWithFreshTimeWindow({ q: value ?? null })}
placeholder="Search log bodies…"
/>
-
- setParams({ range: next })} />
+
+ updateParamsWithFreshTimeWindow({ range: next })}
+ />
)
diff --git a/bun.lock b/bun.lock
index 1ff7de397..ce15abccf 100644
--- a/bun.lock
+++ b/bun.lock
@@ -200,9 +200,11 @@
},
"devDependencies": {
"@tailwindcss/vite": "catalog:tailwind",
+ "@testing-library/react": "^16.3.2",
"@types/react": "catalog:react",
"@types/react-dom": "catalog:react",
"@vitejs/plugin-react": "^6.0.2",
+ "jsdom": "^29.1.1",
"tailwindcss": "catalog:tailwind",
"tw-animate-css": "catalog:tailwind",
"typescript": "catalog:tooling",
diff --git a/packages/query-engine/src/ch/queries/logs.test.ts b/packages/query-engine/src/ch/queries/logs.test.ts
index 462bda3f7..6e1c6aa88 100644
--- a/packages/query-engine/src/ch/queries/logs.test.ts
+++ b/packages/query-engine/src/ch/queries/logs.test.ts
@@ -293,6 +293,21 @@ describe("logsBreakdownQuery", () => {
const { sql } = compileCH(q, baseParams)
expect(sql).toContain("LIMIT 25")
})
+
+ it("can force an unbounded exact raw scan for retention-aligned service membership", () => {
+ const { sql } = compileCH(logsBreakdownQuery({ groupBy: "service", limit: null, source: "raw" }), {
+ ...baseParams,
+ startTime: "2024-01-01 13:05:00",
+ endTime: "2024-01-01 14:05:00",
+ })
+ expect(sql).toContain("FROM logs")
+ expect(sql).not.toContain("logs_aggregates_hourly")
+ expect(sql).toContain("TimestampTime >= '2024-01-01 13:05:00'")
+ expect(sql).toContain("TimestampTime <= '2024-01-01 14:05:00'")
+ expect(sql).toContain("Timestamp >= '2024-01-01 13:05:00'")
+ expect(sql).toContain("Timestamp <= '2024-01-01 14:05:00'")
+ expect(sql).not.toContain("LIMIT")
+ })
})
// ---------------------------------------------------------------------------
diff --git a/packages/query-engine/src/ch/queries/logs.ts b/packages/query-engine/src/ch/queries/logs.ts
index 3ca21b4b8..aba6ef1e0 100644
--- a/packages/query-engine/src/ch/queries/logs.ts
+++ b/packages/query-engine/src/ch/queries/logs.ts
@@ -345,7 +345,10 @@ function buildLogsGroupNameExpr(
export interface LogsBreakdownOpts extends LogsQueryOpts {
groupBy: "service" | "severity"
- limit?: number
+ /** Maximum groups to return. Pass `null` when complete membership is required. */
+ limit?: number | null
+ /** Force an exact raw-log scan when aggregate retention is not semantically equivalent. */
+ source?: "auto" | "raw"
}
export interface LogsBreakdownOutput {
@@ -361,7 +364,7 @@ function logsBreakdownName(
}
export function logsBreakdownQuery(opts: LogsBreakdownOpts): CHQuery {
- if (!canUseLogsAggregateInterior(opts)) {
+ if (opts.source === "raw" || !canUseLogsAggregateInterior(opts)) {
const raw = from(Logs)
.select(($) => ({
name: logsBreakdownName($, opts.groupBy),
@@ -382,9 +385,8 @@ export function logsBreakdownQuery(opts: LogsBreakdownOpts): CHQuery
+ const result = opts.limit === null ? raw.format("JSON") : raw.limit(opts.limit ?? 10).format("JSON")
+ return result as unknown as CHQuery
}
const rawEdges = from(Logs)
@@ -431,9 +433,9 @@ export function logsBreakdownQuery(opts: LogsBreakdownOpts): CHQuery
+ const result =
+ opts.limit === null ? combined.format("JSON") : combined.limit(opts.limit ?? 10).format("JSON")
+ return result as unknown as CHQuery
}
// ---------------------------------------------------------------------------