Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
11 changes: 0 additions & 11 deletions services/intake/src/nmp/intake/spans/api/spans.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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)
10 changes: 0 additions & 10 deletions services/intake/src/nmp/intake/spans/api/traces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
10 changes: 4 additions & 6 deletions services/intake/tests/integration/spans/test_atif_ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
7 changes: 7 additions & 0 deletions services/intake/tests/test_traces_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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<string | null> = [];
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(<IntakeSpansTable workspace="default" />, {
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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 = [
Expand Down Expand Up @@ -113,7 +119,23 @@ export interface IntakeSpansTableProps {
onRowClick?: ((span: SpanTableRow) => void) | null;
}

export const IntakeSpansTable: FC<IntakeSpansTableProps> = ({
export const IntakeSpansTable: FC<IntakeSpansTableProps> = (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 <SeededIntakeSpansTable {...props} defaultStartedAtFilter={defaultStartedAtFilter} />;
};

const SeededIntakeSpansTable: FC<
IntakeSpansTableProps & { defaultStartedAtFilter: StartedAtFilterEntry | null }
> = ({
workspace: workspaceProp,
slotEndPortalTargetId,
fixedFilter,
Expand All @@ -127,6 +149,7 @@ export const IntakeSpansTable: FC<IntakeSpansTableProps> = ({
emptyStateActions,
noResultsActions,
onRowClick,
defaultStartedAtFilter,
}) => {
const navigate = useNavigate();
const routeWorkspace = useWorkspaceFromPathIfExists();
Expand All @@ -148,7 +171,12 @@ export const IntakeSpansTable: FC<IntakeSpansTableProps> = ({
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> = [];
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(<IntakeTracesTable workspace="default" />, {
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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand All @@ -33,11 +39,25 @@ export interface IntakeTracesTableProps {
noResultsActions?: ReactNode;
}

export const IntakeTracesTable: FC<IntakeTracesTableProps> = ({
export const IntakeTracesTable: FC<IntakeTracesTableProps> = (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 <SeededIntakeTracesTable {...props} defaultStartedAtFilter={defaultStartedAtFilter} />;
};

const SeededIntakeTracesTable: FC<
IntakeTracesTableProps & { defaultStartedAtFilter: StartedAtFilterEntry }
> = ({
workspace: workspaceProp,
slotEndPortalTargetId,
emptyStateActions,
noResultsActions,
defaultStartedAtFilter,
}) => {
const navigate = useNavigate();
const routeWorkspace = useWorkspaceFromPathIfExists();
Expand All @@ -49,7 +69,11 @@ export const IntakeTracesTable: FC<IntakeTracesTableProps> = ({
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,
Expand Down
Loading
Loading