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:
parent
8f60dbcc82
commit
72f7bff378
|
|
@ -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
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'`,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'`,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue