diff --git a/services/intake/src/nmp/intake/spans/api/spans.py b/services/intake/src/nmp/intake/spans/api/spans.py index 878bab79e8..7ebc28c9bb 100644 --- a/services/intake/src/nmp/intake/spans/api/spans.py +++ b/services/intake/src/nmp/intake/spans/api/spans.py @@ -5,8 +5,6 @@ from __future__ import annotations -from datetime import timedelta - from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from nmp.common.api.common import Page from nmp.common.api.filter import FilterOperator @@ -33,11 +31,9 @@ ) from nmp.intake.spans.domain import SpanAttributeFilter, SpanListFilter from nmp.intake.spans.service import SpanNotFoundError -from nmp.intake.spans.storage import utc_now router = APIRouter(dependencies=[Depends(require_workspace_access)]) API_TAG = "Spans" -DEFAULT_LIST_LOOKBACK_DAYS = 30 ATTRIBUTE_EQ_FILTER_FIELDS = frozenset( { "project", @@ -85,7 +81,6 @@ async def list_spans( ) -> Page[Span]: validate_list_query_params(request, additional_params={"mode"}) filters = _span_filter(workspace, parsed) - _apply_default_time_bound(filters) result = await service.list_spans( filters=filters, page=page, @@ -141,7 +136,6 @@ async def list_span_groups( validate_list_query_params(request, additional_params={"by"}) grouped_by = _parse_group_by(by) filters = _span_filter(workspace, parsed) - _apply_default_time_bound(filters) result = await service.list_span_groups( filters=filters, group_by=[field.value for field in grouped_by], @@ -234,8 +228,3 @@ def _span_filter(workspace: str, parsed: ParsedFilter) -> SpanListFilter: def _add_attribute_eq_filter(filters: SpanListFilter, field: str, value: str) -> None: filters.attribute_filters.append(SpanAttributeFilter(field=field, operator=FilterOperator.EQ.value, value=value)) - - -def _apply_default_time_bound(filters: SpanListFilter) -> None: - if filters.started_at_gte is None and filters.started_at_lte is None: - filters.started_at_gte = utc_now() - timedelta(days=DEFAULT_LIST_LOOKBACK_DAYS) diff --git a/services/intake/src/nmp/intake/spans/api/traces.py b/services/intake/src/nmp/intake/spans/api/traces.py index f2ee3ab6bb..8767b6f1cd 100644 --- a/services/intake/src/nmp/intake/spans/api/traces.py +++ b/services/intake/src/nmp/intake/spans/api/traces.py @@ -5,8 +5,6 @@ from __future__ import annotations -from datetime import timedelta - from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from nmp.common.api.common import Page from nmp.common.api.filter import FilterOperator @@ -22,11 +20,9 @@ from nmp.intake.spans.api.traces_schemas import Trace, TraceFilter, TraceMode, TraceSortField from nmp.intake.spans.domain import SpanStatus, TraceListFilter from nmp.intake.spans.service import TraceNotFoundError -from nmp.intake.spans.storage import utc_now router = APIRouter(dependencies=[Depends(require_workspace_access)]) API_TAG = "Traces" -DEFAULT_LIST_LOOKBACK_DAYS = 30 TRACE_INDEX_FILTER_FIELDS = frozenset( { "experiment_id", @@ -67,7 +63,6 @@ async def list_traces( ) -> Page[Trace]: validate_list_query_params(request, additional_params={"mode"}) filters = _trace_filter(workspace, parsed) - _apply_default_time_bound(filters) result = await service.list_traces( filters=filters, page=page, @@ -143,8 +138,3 @@ def _set_trace_index_filter(filters: TraceListFilter, public_field: str, value: detail=f"Conflicting trace filters for {field}", ) setattr(filters, field, value) - - -def _apply_default_time_bound(filters: TraceListFilter) -> None: - if filters.started_at_gte is None and filters.started_at_lte is None: - filters.started_at_gte = utc_now() - timedelta(days=DEFAULT_LIST_LOOKBACK_DAYS) diff --git a/services/intake/tests/integration/spans/test_atif_ingest.py b/services/intake/tests/integration/spans/test_atif_ingest.py index c5862f49af..92218ee29b 100644 --- a/services/intake/tests/integration/spans/test_atif_ingest.py +++ b/services/intake/tests/integration/spans/test_atif_ingest.py @@ -12,10 +12,8 @@ _HISTORICAL_GTE = "2024-01-01T00:00:00Z" - -def _recent_base_time() -> datetime: - """Return a UTC timestamp safely inside the spans list 30-day default lookback.""" - return datetime.now(timezone.utc) - timedelta(hours=2) +# Frozen so ingested timestamps (and everything derived from them) are deterministic. +_BASE_TIME = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc) def _atif_timestamp(dt: datetime) -> str: @@ -193,7 +191,7 @@ def test_atif_ingest_accepts_example_trajectory_and_reconstructs_read_side_data( "metadata": {"trial": "sample-test-case-a__trial-a"}, } _create_experiment(client, evaluation_context["evaluation_id"]) - base_time = _recent_base_time() + base_time = _BASE_TIME user_step_time = base_time agent_step_2_time = base_time + timedelta(seconds=5, milliseconds=636) agent_step_3_time = base_time + timedelta(seconds=10, milliseconds=528) @@ -587,7 +585,7 @@ def test_atif_trace_tokens_do_not_double_count_when_trajectory_and_steps_both_ca per-step metrics that sum to it. The trajectory span must NOT carry token attributes, or the trace-level rollup would sum them and report 2x the real total. """ - base_time = _recent_base_time() + base_time = _BASE_TIME user_step_time = base_time agent_step_2_time = base_time + timedelta(seconds=5) agent_step_3_time = base_time + timedelta(seconds=10) diff --git a/services/intake/tests/integration/spans/test_chat_completions_ingest.py b/services/intake/tests/integration/spans/test_chat_completions_ingest.py index af5a87a2cc..f452fc1340 100644 --- a/services/intake/tests/integration/spans/test_chat_completions_ingest.py +++ b/services/intake/tests/integration/spans/test_chat_completions_ingest.py @@ -51,7 +51,6 @@ def _openai_response(**overrides: Any) -> dict[str, Any]: response = { "id": "chatcmpl-test-abc123", "object": "chat.completion", - # Keep within the spans list default 30-day started_at lookback. "created": int(datetime.now(timezone.utc).timestamp()), "model": "gpt-4o-mini-2024-08-06", "choices": [ diff --git a/services/intake/tests/test_traces_api.py b/services/intake/tests/test_traces_api.py index f1c9f8f61b..7240f3acd5 100644 --- a/services/intake/tests/test_traces_api.py +++ b/services/intake/tests/test_traces_api.py @@ -67,5 +67,12 @@ def test_trace_filter_schema_keeps_trace_index_filters_canonical(): assert "deprecated" not in properties["test_case_id"] +def test_trace_filter_applies_no_implicit_time_bound(): + filters = _trace_filter("workspace-a", _parsed_filter({"id": "trace-a"})) + + assert filters.started_at_gte is None + assert filters.started_at_lte is None + + def _parsed_filter(value: dict[str, object]) -> ParsedFilter: return ParsedFilter(operation=parse_json_filter(json.dumps(value))) diff --git a/web/packages/studio/src/components/IntakeLists/IntakeSpansTable.test.tsx b/web/packages/studio/src/components/IntakeLists/IntakeSpansTable.test.tsx index f336e82d98..06c5097d2d 100644 --- a/web/packages/studio/src/components/IntakeLists/IntakeSpansTable.test.tsx +++ b/web/packages/studio/src/components/IntakeLists/IntakeSpansTable.test.tsx @@ -2,8 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { IntakeSpansTable } from '@studio/components/IntakeLists/IntakeSpansTable'; -import { renderRoute, screen } from '@studio/tests/util/render'; +import { mockSpansPage } from '@studio/mocks/intake/telemetry'; +import { server } from '@studio/mocks/node'; +import { renderRoute, screen, waitFor } from '@studio/tests/util/render'; import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; import { useLocation } from 'react-router-dom'; const LocationProbe = () => { @@ -38,6 +41,32 @@ describe('IntakeSpansTable', () => { ); }); + it('seeds a clearable 30-day started_at filter into span list requests', async () => { + const user = userEvent.setup(); + const startedAtParams: Array = []; + server.use( + http.get('*/apis/intake/v2/workspaces/:workspace/spans', ({ request }) => { + startedAtParams.push(new URL(request.url).searchParams.get('filter[started_at][$gte]')); + return HttpResponse.json(mockSpansPage); + }) + ); + + renderRoute(, { + history: '/workspaces/default/intake/spans', + }); + + await screen.findByText('Answer customer policy question'); + await waitFor(() => expect(startedAtParams.filter(Boolean).length).toBeGreaterThan(0)); + + const seededGte = new Date(startedAtParams.filter(Boolean).at(-1) as string); + const daysAgo = (Date.now() - seededGte.getTime()) / 86_400_000; + expect(daysAgo).toBeGreaterThanOrEqual(29); + expect(daysAgo).toBeLessThanOrEqual(31); + + await user.click(screen.getByTestId('clear-filters')); + await waitFor(() => expect(startedAtParams.at(-1)).toBeNull()); + }); + it('shows explicit span filter facets', async () => { const user = userEvent.setup(); diff --git a/web/packages/studio/src/components/IntakeLists/IntakeSpansTable.tsx b/web/packages/studio/src/components/IntakeLists/IntakeSpansTable.tsx index 39c7cd64e4..90242d8dda 100644 --- a/web/packages/studio/src/components/IntakeLists/IntakeSpansTable.tsx +++ b/web/packages/studio/src/components/IntakeLists/IntakeSpansTable.tsx @@ -18,6 +18,12 @@ import { import { Anchor, Button, Text } from '@nvidia/foundations-react-core'; import { getErrorMessage } from '@studio/api/common/utils'; import { IntakeTelemetryStatusBadge } from '@studio/components/IntakeDetail/IntakeComponents/IntakeTelemetryStatusBadge'; +import { + isDefaultStartedAtFilter, + makeDefaultStartedAtFilter, + type StartedAtFilterEntry, + useSeededStartedAtFilter, +} from '@studio/components/IntakeLists/defaultStartedAtFilter'; import { IntakeTelemetryDataView } from '@studio/components/IntakeLists/IntakeTelemetryDataView'; import { useWorkspaceFromPathIfExists } from '@studio/hooks/useWorkspaceFromPath'; import { getIntakeTraceSpanRoute } from '@studio/routes/utils'; @@ -32,7 +38,7 @@ import { type SpanTableRow, } from '@studio/util/intakeTelemetry'; import { keepPreviousData } from '@tanstack/react-query'; -import { type ComponentProps, type FC, type ReactNode, useMemo } from 'react'; +import { type ComponentProps, type FC, type ReactNode, useMemo, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; const SPAN_STATUS_FILTER_OPTIONS = [ @@ -113,7 +119,23 @@ export interface IntakeSpansTableProps { onRowClick?: ((span: SpanTableRow) => void) | null; } -export const IntakeSpansTable: FC = ({ +export const IntakeSpansTable: FC = (props) => { + // Seed the default started_at filter into the URL before the table mounts, + // so the dataview state initializes from it directly — one render, one + // request, no unfiltered first fetch. Scoped embeds (fixedFilter) are + // already bounded and skip the seed. + const [defaultStartedAtFilter] = useState(() => + props.fixedFilter ? null : makeDefaultStartedAtFilter() + ); + const filtersSeeded = useSeededStartedAtFilter(defaultStartedAtFilter); + + if (!filtersSeeded) return null; + return ; +}; + +const SeededIntakeSpansTable: FC< + IntakeSpansTableProps & { defaultStartedAtFilter: StartedAtFilterEntry | null } +> = ({ workspace: workspaceProp, slotEndPortalTargetId, fixedFilter, @@ -127,6 +149,7 @@ export const IntakeSpansTable: FC = ({ emptyStateActions, noResultsActions, onRowClick, + defaultStartedAtFilter, }) => { const navigate = useNavigate(); const routeWorkspace = useWorkspaceFromPathIfExists(); @@ -148,7 +171,12 @@ export const IntakeSpansTable: FC = ({ defaultPageSize, }); - const hasActiveFilters = dataViewState.debouncedColumnFilters.length > 0; + // The seeded started_at default doesn't count as user filtering: an empty + // workspace should still get the first-run empty state. + const hasActiveFilters = dataViewState.debouncedColumnFilters.some( + (filter) => + defaultStartedAtFilter === null || !isDefaultStartedAtFilter(filter, defaultStartedAtFilter) + ); const { data: spansResponse, diff --git a/web/packages/studio/src/components/IntakeLists/IntakeTracesTable.test.tsx b/web/packages/studio/src/components/IntakeLists/IntakeTracesTable.test.tsx index 4cfe020334..68b837f7dc 100644 --- a/web/packages/studio/src/components/IntakeLists/IntakeTracesTable.test.tsx +++ b/web/packages/studio/src/components/IntakeLists/IntakeTracesTable.test.tsx @@ -28,6 +28,32 @@ describe('IntakeTracesTable', () => { expect(requestedModes).not.toContain('summary'); }); + it('seeds a clearable 30-day started_at filter into trace list requests', async () => { + const user = userEvent.setup(); + const startedAtParams: Array = []; + server.use( + http.get('*/apis/intake/v2/workspaces/:workspace/traces', ({ request }) => { + startedAtParams.push(new URL(request.url).searchParams.get('filter[started_at][$gte]')); + return HttpResponse.json(mockTracesPage); + }) + ); + + renderRoute(, { + history: '/workspaces/default/intake/traces', + }); + + await screen.findByText('Answer customer policy question'); + await waitFor(() => expect(startedAtParams.filter(Boolean).length).toBeGreaterThan(0)); + + const seededGte = new Date(startedAtParams.filter(Boolean).at(-1) as string); + const daysAgo = (Date.now() - seededGte.getTime()) / 86_400_000; + expect(daysAgo).toBeGreaterThanOrEqual(29); + expect(daysAgo).toBeLessThanOrEqual(31); + + await user.click(screen.getByTestId('clear-filters')); + await waitFor(() => expect(startedAtParams.at(-1)).toBeNull()); + }); + it('shows explicit trace filter facets', async () => { const user = userEvent.setup(); diff --git a/web/packages/studio/src/components/IntakeLists/IntakeTracesTable.tsx b/web/packages/studio/src/components/IntakeLists/IntakeTracesTable.tsx index 4a325f2687..b1ff59abcb 100644 --- a/web/packages/studio/src/components/IntakeLists/IntakeTracesTable.tsx +++ b/web/packages/studio/src/components/IntakeLists/IntakeTracesTable.tsx @@ -12,6 +12,12 @@ import { useListTraces } from '@nemo/sdk/generated/platform/api'; import type { Trace, TraceFilter, TraceSortField } from '@nemo/sdk/generated/platform/schema'; import { Badge, Button } from '@nvidia/foundations-react-core'; import { getErrorMessage } from '@studio/api/common/utils'; +import { + isDefaultStartedAtFilter, + makeDefaultStartedAtFilter, + type StartedAtFilterEntry, + useSeededStartedAtFilter, +} from '@studio/components/IntakeLists/defaultStartedAtFilter'; import { IntakeTelemetryDataView } from '@studio/components/IntakeLists/IntakeTelemetryDataView'; import { useWorkspaceFromPathIfExists } from '@studio/hooks/useWorkspaceFromPath'; import { getIntakeTraceRoute } from '@studio/routes/utils'; @@ -23,7 +29,7 @@ import { } from '@studio/util/intakeTelemetry'; import { keepPreviousData } from '@tanstack/react-query'; import { Columns3 } from 'lucide-react'; -import type { ComponentProps, FC, ReactNode } from 'react'; +import { type ComponentProps, type FC, type ReactNode, useState } from 'react'; import { useNavigate } from 'react-router-dom'; export interface IntakeTracesTableProps { @@ -33,11 +39,25 @@ export interface IntakeTracesTableProps { noResultsActions?: ReactNode; } -export const IntakeTracesTable: FC = ({ +export const IntakeTracesTable: FC = (props) => { + // Seed the default started_at filter into the URL before the table mounts, + // so the dataview state initializes from it directly — one render, one + // request, no unfiltered first fetch. + const [defaultStartedAtFilter] = useState(makeDefaultStartedAtFilter); + const filtersSeeded = useSeededStartedAtFilter(defaultStartedAtFilter); + + if (!filtersSeeded) return null; + return ; +}; + +const SeededIntakeTracesTable: FC< + IntakeTracesTableProps & { defaultStartedAtFilter: StartedAtFilterEntry } +> = ({ workspace: workspaceProp, slotEndPortalTargetId, emptyStateActions, noResultsActions, + defaultStartedAtFilter, }) => { const navigate = useNavigate(); const routeWorkspace = useWorkspaceFromPathIfExists(); @@ -49,7 +69,11 @@ export const IntakeTracesTable: FC = ({ defaultSort: { id: 'started_at', desc: true }, }); - const hasActiveFilters = dataViewState.debouncedColumnFilters.length > 0; + // The seeded started_at default doesn't count as user filtering: an empty + // workspace should still get the first-run empty state. + const hasActiveFilters = dataViewState.debouncedColumnFilters.some( + (filter) => !isDefaultStartedAtFilter(filter, defaultStartedAtFilter) + ); const { data: tracesResponse, diff --git a/web/packages/studio/src/components/IntakeLists/defaultStartedAtFilter.ts b/web/packages/studio/src/components/IntakeLists/defaultStartedAtFilter.ts new file mode 100644 index 0000000000..3daec5f5f9 --- /dev/null +++ b/web/packages/studio/src/components/IntakeLists/defaultStartedAtFilter.ts @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +export const INTAKE_DEFAULT_LOOKBACK_DAYS = 30; + +export interface StartedAtFilterEntry { + id: 'started_at'; + value: { $gte: string }; +} + +/** + * Default window for the intake browse views: telemetry started in the last + * 30 days. Seeded as a visible, clearable column filter so unbounded history + * is one click away — the intake API itself applies no implicit time bound. + */ +export const makeDefaultStartedAtFilter = (): StartedAtFilterEntry => { + const from = new Date(); + from.setDate(from.getDate() - INTAKE_DEFAULT_LOOKBACK_DAYS); + from.setHours(0, 0, 0, 0); + return { id: 'started_at', value: { $gte: from.toISOString() } }; +}; + +/** Whether a column filter entry is exactly the seeded default (untouched by the user). */ +export const isDefaultStartedAtFilter = ( + filter: { id: string; value: unknown }, + defaultFilter: StartedAtFilterEntry +): boolean => + filter.id === defaultFilter.id && + JSON.stringify(filter.value) === JSON.stringify(defaultFilter.value); + +/** + * Seed the default filter into the URL `filters` param when a browse view + * mounts without one. `useStudioDataViewState` then adopts it exactly like a + * user-set filter (chip, URL sharing, clearing), so no shared-hook changes are + * needed. Clearing lasts for the visit; the next mount without a `filters` + * param re-seeds. + * + * Returns false until the URL reflects the seed — gate list queries on it so + * the first request is never accidentally unbounded. + */ +export const useSeededStartedAtFilter = (defaultFilter: StartedAtFilterEntry | null): boolean => { + const [searchParams, setSearchParams] = useSearchParams(); + const [seeded, setSeeded] = useState(() => defaultFilter === null || searchParams.has('filters')); + + useEffect(() => { + if (seeded || defaultFilter === null) return; + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (!next.has('filters')) { + // Match useStudioDataViewState's encoding: it decodeURIComponent()s + // the param value before JSON.parse. + next.set('filters', encodeURIComponent(JSON.stringify([defaultFilter]))); + } + return next; + }, + { replace: true } + ); + setSeeded(true); + }, [seeded, defaultFilter, setSearchParams]); + + return seeded; +};