perf: optimize AI session list queries (#13574)

## Summary

- Page sessions by `session_id` before computing the expensive summary
fields.
- Aggregate only the selected page of sessions with exact
`count(DISTINCT trace_id)`, removing trace ID materialization and
Rust-side per-trace rollups.
- Generate direct Agent predicates and evaluate them as aggregate
membership conditions without a session-membership subquery.
- Support efficient server pagination by fetching one extra session to
determine `has_more`, without counting every distinct session on every
page.

## Why

The previous endpoint first materialized `array_agg(DISTINCT trace_id)`
for every candidate session and then issued another query using those
trace IDs. A single-pass full rollup was also slower because it
aggregated every summary field for all matching sessions before applying
TopK.

This change keeps the inexpensive session page selection separate and
bounds the expensive rollup to the requested page size. Page selection
and display both use the latest `end_time` across the complete session;
Agent predicates only decide whether a session is a member of the result
set.

The endpoint previously returned `total = hits.len()`. With a 20-row
page, OTable therefore calculated one total page and permanently
disabled Next even when more sessions existed. An exact `count(DISTINCT
session_id)` query scanned the full data set and took about 1.18 seconds
by itself on this benchmark. Instead, the page query now requests `size
+ 1`: while another page exists, `total` is a documented lower bound and
the response includes `has_more=true` / `total_is_exact=false`. The
final page returns the exact total. OTable displays lower bounds with
`+` and hides Last-page navigation until the exact total is known.

## Performance

Measured locally against `bench_traces` (167 files, about 99.9 million
records), using five runs and ignoring the first two:

| Query shape | Mean wall time |
| --- | ---: |
| Original trace-ID flow | 6.829 s |
| Page session IDs + membership subquery | 3.219 s |
| Page session IDs + Last activity | 0.850 s |

The optimized query shape is about 87.6% faster than the original flow.
The second-phase rollup returned 20 rows with about 74 KB of
intermediate output and about 2.45 MB peak memory in the query plan.
Fetching the extra pagination sentinel did not introduce a meaningful
page-size-dependent regression in the final runtime sweep.

## Behavior note

The Agent filter controls membership through `HAVING max(CASE WHEN
<filter> THEN 1 ELSE 0 END) = 1`, while the final rollup includes all
spans for each selected session. Both pagination and display use the
complete session's `max(end_time)`, ordered by `session_last_activity
DESC, session_id DESC` for stable offset pagination. The UI labels this
value `Last activity`; session start and duration remain available from
the rollup.

Current clients send direct Agent predicates. For compatibility, the
endpoint also recognizes the previous narrow same-stream `session_id IN
(SELECT session_id ... GROUP BY session_id)` shape and safely extracts
its inner WHERE predicate; unrelated subqueries are left unchanged.

This assumes every relevant span has a `session_id`.

## Validation

- `cargo build --features mimalloc --profile release`
- `cargo test -p openobserve-api-search traces::session --lib` (18
passed)
- `cargo test -p openobserve-api-search user::tests --lib` (13 passed in
the earlier query-change validation)
- `cargo fmt --all`
- `git diff --check`
- targeted Vitest: Sessions composable, SessionsList, and OTable (169
passed)
- `vue-tsc --noEmit -p tsconfig.vitest.json --composite false`
- targeted ESLint and Prettier checks
- runtime pagination check against the supplied legacy-filter curl:
  - page 1: 20 hits, `total=21`, `has_more=true`, `total_is_exact=false`
  - page 2: 20 hits, `total=41`, `has_more=true`, `total_is_exact=false`
- final page: 3 hits, `total=184443`, `has_more=false`,
`total_is_exact=true`
- pages 1 and 2 returned 40 unique sessions in descending `end_time`
order across the page boundary
This commit is contained in:
Hengfei Yang 2026-08-01 16:35:55 +08:00 committed by GitHub
parent 8f60dbcc82
commit 72f7bff378
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 511 additions and 874 deletions

View File

@ -56,26 +56,6 @@ pub(crate) mod schema_compat;
pub mod session;
pub mod user;
#[derive(Default, Clone, Debug)]
pub(crate) struct TraceDetail {
pub(crate) start_time: i64,
pub(crate) end_time: i64,
pub(crate) gen_ai_usage_input_tokens: i64,
pub(crate) gen_ai_usage_output_tokens: i64,
pub(crate) gen_ai_usage_total_tokens: i64,
pub(crate) gen_ai_usage_cost: f64,
pub(crate) gen_ai_usage_cache_read_input_tokens: i64,
pub(crate) gen_ai_usage_cache_creation_input_tokens: i64,
pub(crate) gen_ai_usage_cost_cache_read_input: f64,
pub(crate) gen_ai_usage_cost_cache_creation_input: f64,
pub(crate) gen_ai_usage_cost_estimated_without_cache: f64,
pub(crate) gen_ai_usage_cost_cache_read_savings: f64,
pub(crate) gen_ai_usage_cost_net_cache_impact: f64,
pub(crate) error_count: i64,
pub(crate) user_id: Option<String>,
pub(crate) first_user_message: Option<String>,
}
/// TracesIngest
#[utoipa::path(
post,

File diff suppressed because it is too large Load Diff

View File

@ -414,7 +414,6 @@ pub async fn get_latest_users(
gen_ai_usage_cost: json::get_float_value(
item.get("gen_ai_usage_cost_details").unwrap_or_default(),
),
..Default::default()
},
);
}
@ -458,7 +457,13 @@ pub async fn get_latest_users(
})
}
use super::TraceDetail;
#[derive(Default, Clone, Debug)]
struct TraceDetail {
start_time: i64,
end_time: i64,
gen_ai_usage_total_tokens: i64,
gen_ai_usage_cost: f64,
}
#[derive(Debug, Serialize)]
struct UserResponseItem {

View File

@ -286,6 +286,28 @@ describe("OTable", () => {
expect(info.text()).toContain("200");
});
it("supports lower-bound totals without pretending the last page is known", async () => {
wrapper = mount(OTable, {
props: {
data: makeRows(20),
columns: makeColumns(),
pagination: "server",
totalCount: 21,
totalCountExact: false,
pageSize: 20,
currentPage: 1,
},
});
expect(wrapper.find('[data-test="o2-table-pagination-info"]').text()).toContain("21+");
expect(wrapper.find('[data-test="o2-table-first-page-btn"]').exists()).toBe(true);
expect(wrapper.find('[data-test="o2-table-last-page-btn"]').exists()).toBe(false);
await wrapper.find('[data-test="o2-table-next-page-btn"]').trigger("click");
const events = wrapper.emitted("pagination-change") as any[][];
expect(events.at(-1)![0]).toEqual({ page: 2, size: 20 });
});
it("should reflect a pageSize prop change in the footer", async () => {
wrapper = mount(OTable, {
props: {

View File

@ -212,6 +212,8 @@ export interface OTableProps<TData = any> {
currentPage?: number;
/** Total record count (required for server-side pagination) */
totalCount?: number;
/** False when totalCount is a lower bound. The footer adds `+` and omits Last page. */
totalCountExact?: boolean;
/** When true, the page index is NOT reset when the data array changes (e.g. on row expand/collapse). Defaults to false. */
keepPageOnDataChange?: boolean;
/** When true, the caller's `#bottom` slot IS the pagination bar and replaces

View File

@ -78,6 +78,7 @@ const props = withDefaults(defineProps<OTableProps<TData>>(), {
filterMode: "client",
defaultColumns: true,
footerTitle: "",
totalCountExact: true,
showHeader: true,
fillHeight: true,
});
@ -1478,6 +1479,7 @@ defineExpose({
:current-page="pagination.currentPage.value"
:total-pages="pagination.totalPages.value"
:total-count="pagination.totalCount.value"
:total-count-exact="props.totalCountExact"
:page-size="pagination.pageSize.value"
:page-size-options="pagination.pageSizeOptions.value"
:showing-from="pagination.showingFrom.value"

View File

@ -15,6 +15,8 @@ const props = withDefaults(
currentPage: number;
totalPages: number;
totalCount: number;
/** False when totalCount is only a lower bound. */
totalCountExact?: boolean;
pageSize: number;
pageSizeOptions: number[];
showingFrom: number;
@ -29,6 +31,7 @@ const props = withDefaults(
{
position: "bottom",
title: "",
totalCountExact: true,
},
);
@ -79,7 +82,9 @@ const pageSizeSelectOptions = computed(() => {
data-test="o2-table-pagination-count-skel"
/>
<slot v-else-if="slots.actions" name="actions" />
<span v-else> {{ totalCount.toLocaleString() }} {{ title }} </span>
<span v-else>
{{ totalCount.toLocaleString() }}{{ totalCountExact ? "" : "+" }} {{ title }}
</span>
</div>
<!-- Right: controls -->
@ -96,7 +101,7 @@ const pageSizeSelectOptions = computed(() => {
data-test="o2-table-pagination-info"
>
{{ t("search.showing") }} {{ showingFrom }} - {{ showingTo }} {{ t("search.of") }}
{{ totalCount.toLocaleString() }}
{{ totalCount.toLocaleString() }}{{ totalCountExact ? "" : "+" }}
</span>
<div class="bg-border-default h-4 w-px shrink-0" v-if="pageSizeOptions.length > 0" />
<div v-if="pageSizeOptions.length > 0" class="text-primary flex items-center gap-1.5 text-xs">
@ -139,6 +144,7 @@ const pageSizeSelectOptions = computed(() => {
<OIcon name="chevron-right" size="sm" />
</OButton>
<OButton
v-if="totalCountExact"
variant="outline"
size="icon"
:disabled="isLastPage"

View File

@ -6863,7 +6863,7 @@
"errorCountTooltip": "{count} errors in this session",
"unknownUser": "Unknown user",
"columns": {
"timestamp": "Timestamp",
"lastActivity": "Last activity",
"sessionId": "Session ID",
"user": "User",
"firstMessage": "First message",

View File

@ -11,6 +11,7 @@ import { ref } from "vue";
// Reactive state that tests can mutate to drive component rendering
const mockSessions = ref<any[]>([]);
const mockTotal = ref(0);
const mockTotalIsExact = ref(true);
const mockLoading = ref(false);
const mockError = ref<string | null>(null);
const mockHasLoadedOnce = ref(false);
@ -33,6 +34,7 @@ vi.mock("./composables/useSessions", () => ({
useSessions: vi.fn(() => ({
sessions: mockSessions,
total: mockTotal,
totalIsExact: mockTotalIsExact,
loading: mockLoading,
error: mockError,
hasLoadedOnce: mockHasLoadedOnce,
@ -94,7 +96,7 @@ vi.mock("vue-i18n", () => ({
vi.mock("@/lib/core/Table/OTable.vue", () => ({
default: {
name: "OTable",
props: ["data", "columns", "loading", "rowKey", "totalCount", "footerTitle"],
props: ["data", "columns", "loading", "rowKey", "totalCount", "totalCountExact", "footerTitle"],
emits: ["row-click"],
// Mirrors the OTable contract the component relies on: a loading state, one
// row per item, the `#empty` slot when there are no rows, and a footer that
@ -114,7 +116,7 @@ vi.mock("@/lib/core/Table/OTable.vue", () => ({
@click="$emit('row-click', row)"
>
<slot name="cell-sessionId" :row="row">{{ row.sessionId }}</slot>
<slot name="cell-firstSeenNanos" :row="row">{{ row.firstSeenNanos }}</slot>
<slot name="cell-lastSeenNanos" :row="row">{{ row.lastSeenNanos }}</slot>
<slot name="cell-turns" :row="row">{{ row.turns }}</slot>
<slot name="cell-durationNanos" :row="row">{{ row.durationNanos }}</slot>
<span data-test="sessions-list-token-cell">
@ -228,6 +230,7 @@ beforeEach(() => {
localStorage.clear();
mockSessions.value = [];
mockTotal.value = 0;
mockTotalIsExact.value = true;
mockLoading.value = false;
mockError.value = null;
mockHasLoadedOnce.value = false;
@ -333,6 +336,20 @@ describe("SessionsList — loading state", () => {
});
describe("SessionsList — sessions table", () => {
it("uses the session end time for the Last activity column", async () => {
mockHasLoadedOnce.value = true;
mockSessions.value = [makeSession()];
const wrapper = await mountComponent();
const table = wrapper.findComponent({ name: "OTable" });
const column = (table.props("columns") as any[]).find((item) => item.id === "lastSeenNanos");
expect(column.header).toBe("traces.sessionsList.columns.lastActivity");
expect(column.accessorKey).toBe("lastSeenNanos");
expect(wrapper.text()).toContain("2023-11-14 22:30:00");
expect(wrapper.text()).not.toContain("2023-11-14 22:13:20");
});
it("should fetch stream sessions with no agent filter when in stream mode", async () => {
// Default scope is "agent" now — stream mode is opted into ONLY via the URL
// `?type=stream` param (a stale saved preference must not land on stream).
@ -378,6 +395,18 @@ describe("SessionsList — sessions table", () => {
expect(footer.text()).toContain("42");
});
it("passes lower-bound count metadata to server pagination", async () => {
mockHasLoadedOnce.value = true;
mockSessions.value = [makeSession()];
mockTotal.value = 21;
mockTotalIsExact.value = false;
const wrapper = await mountComponent();
const table = wrapper.findComponent({ name: "OTable" });
expect(table.props("totalCount")).toBe(21);
expect(table.props("totalCountExact")).toBe(false);
});
it("status badge shows 'ok' status for ok sessions", async () => {
mockHasLoadedOnce.value = true;
mockSessions.value = [makeSession({ sessionId: "sess-ok", status: "ok" })];
@ -443,7 +472,7 @@ describe("SessionsList — agent filter", () => {
2000,
0,
20,
`gen_ai_conversation_id IN (SELECT gen_ai_conversation_id FROM "agent-stream" WHERE gen_ai_conversation_id IS NOT NULL AND gen_ai_conversation_id != '' AND gen_ai_agent_id = 'agent-1' GROUP BY gen_ai_conversation_id)`,
`gen_ai_agent_id = 'agent-1'`,
);
});

View File

@ -80,6 +80,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
pagination="server"
:current-page="currentPage"
:total-count="total"
:total-count-exact="totalIsExact"
:page-size="rowsPerPage"
:page-size-options="rowsPerPageOptions"
:footer-title="t('traces.sessionsList.sessions')"
@ -124,10 +125,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OEmptyState size="hero" preset="no-llm-sessions" @action="onEmptyAction" />
</div>
</template>
<!-- Timestamp -->
<template #cell-firstSeenNanos="{ row }">
<!-- Last activity -->
<template #cell-lastSeenNanos="{ row }">
<span class="text-xs tabular-nums">
{{ formatTimestamp(row.firstSeenNanos) }}
{{ formatTimestamp(row.lastSeenNanos) }}
</span>
</template>
@ -247,6 +248,7 @@ const store = useStore();
const {
sessions,
total,
totalIsExact,
loading,
error,
hasLoadedOnce,
@ -367,9 +369,9 @@ watch(total, () => {
const tableColumns = computed(() =>
[
{
id: "firstSeenNanos",
header: t("traces.sessionsList.columns.timestamp"),
accessorKey: "firstSeenNanos",
id: "lastSeenNanos",
header: t("traces.sessionsList.columns.lastActivity"),
accessorKey: "lastSeenNanos",
size: 170,
sortable: false,
hideable: true,
@ -516,6 +518,7 @@ function syncFilterUrl() {
function clearSessionRows() {
sessions.value = [];
total.value = 0;
totalIsExact.value = true;
}
async function loadSessions(startTime?: number, endTime?: number, force = false) {

View File

@ -50,6 +50,7 @@ beforeEach(() => {
const s = useSessions();
s.sessions.value = [];
s.total.value = 0;
s.totalIsExact.value = true;
s.loading.value = false;
s.error.value = null;
s.hasLoadedOnce.value = false;
@ -178,6 +179,18 @@ describe("useSessions — fetchPage: field mapping", () => {
expect(total.value).toBe(42);
});
it("marks a lower-bound total as inexact while another page exists", async () => {
mockSessionsList.mockResolvedValue({
data: { hits: [], total: 21, has_more: true, total_is_exact: false },
});
const { total, totalIsExact, fetchPage } = useSessions();
await fetchPage("stream", 1000, 2000, 0, 20);
expect(total.value).toBe(21);
expect(totalIsExact.value).toBe(false);
});
it("sets hasLoadedOnce=true after successful fetch", async () => {
mockSessionsList.mockResolvedValue({ data: { hits: [], total: 0 } });

View File

@ -145,6 +145,7 @@ export interface SessionRow {
// ---------------------------------------------------------------------------
const sessions = ref<SessionRow[]>([]);
const total = ref(0);
const totalIsExact = ref(true);
const loading = ref(false);
const error = ref<string | null>(null);
const hasLoadedOnce = ref(false);
@ -251,6 +252,7 @@ export function useSessions() {
};
});
total.value = Number(body.total) || 0;
totalIsExact.value = body.total_is_exact ?? true;
hasLoadedOnce.value = true;
// Stamp when/which-org this page was fetched — used to keep the "last
// refreshed" label accurate and to invalidate the cache on org switch.
@ -616,6 +618,7 @@ export function useSessions() {
return {
sessions,
total,
totalIsExact,
loading,
error,
hasLoadedOnce,

View File

@ -58,15 +58,13 @@ describe("llmAgentFilter", () => {
expect(where).toContain("gen_ai_agent_env = 'production'");
});
it("builds a session-membership filter that keeps full matching sessions", () => {
expect(buildAgentSessionFilter(agentWithId, "default")).toBe(
`gen_ai_conversation_id IN (SELECT gen_ai_conversation_id FROM "default" WHERE gen_ai_conversation_id IS NOT NULL AND gen_ai_conversation_id != '' AND gen_ai_agent_id = 'agent-123' GROUP BY gen_ai_conversation_id)`,
);
it("builds a direct agent predicate for session selection", () => {
expect(buildAgentSessionFilter(agentWithId, "default")).toBe(`gen_ai_agent_id = 'agent-123'`);
});
it("supports a custom session field for session-membership filters", () => {
it("keeps the same predicate when the backend uses a custom session field", () => {
expect(buildAgentSessionFilter(agentWithId, "default", "llm_session_id")).toBe(
`llm_session_id IN (SELECT llm_session_id FROM "default" WHERE llm_session_id IS NOT NULL AND llm_session_id != '' AND gen_ai_agent_id = 'agent-123' GROUP BY llm_session_id)`,
`gen_ai_agent_id = 'agent-123'`,
);
});

View File

@ -60,18 +60,16 @@ export function buildAgentTraceFilter(
}
/**
* Build a session-level predicate for the LLM Sessions list. This differs from
* `buildAgentTraceFilter`: first find sessions that contain at least one trace
* for the selected agent, then let the outer sessions query collect every trace
* in those sessions. That preserves full-conversation totals and first-message
* derivation while still filtering the visible session list by agent.
* Build the agent predicate used to select a page of LLM sessions. The backend
* applies this predicate only while choosing session ids, then runs the final
* rollup over every span in those sessions. A direct predicate therefore keeps
* full-conversation totals without the previous session-membership subquery.
*/
export function buildAgentSessionFilter(
agent: GenAiAgentListItem | null | undefined,
streamName: string,
sessionField = "gen_ai_conversation_id",
): string {
const traceFilter = buildAgentTraceFilter(agent, streamName);
if (!traceFilter || !sessionField) return "";
return `${sessionField} IN (SELECT ${sessionField} FROM "${streamName}" WHERE ${sessionField} IS NOT NULL AND ${sessionField} != '' AND ${traceFilter} GROUP BY ${sessionField})`;
if (!sessionField) return "";
return buildAgentTraceFilter(agent, streamName);
}

View File

@ -45,12 +45,17 @@ export interface SessionApiHit {
export interface SessionApiResponse {
took: number;
/** Exact on the last page; otherwise a lower bound sufficient to enable Next. */
total: number;
from: number;
size: number;
hits: SessionApiHit[];
trace_id?: string;
function_error?: string;
/** Whether at least one session exists after this page. */
has_more: boolean;
/** False when `total` is only the known lower bound used for pagination. */
total_is_exact: boolean;
}
export interface SessionDetailsApiResponse {
@ -69,9 +74,8 @@ const sessions = {
* `GET /api/{org_id}/{stream_name}/traces/session`.
*
* Server expects microsecond timestamps for `start_time`/`end_time` and
* does the GROUP BY + per-session aggregation in two phases (session
* trace_id list, then per-trace gen_ai usage rollup) so the frontend
* doesn't need to build the SQL itself.
* pages by the complete session's latest end_time, then aggregates the
* selected session IDs, so the frontend doesn't need to build SQL itself.
*
* @example
* await sessions.list({