diff --git a/CHANGELOG.md b/CHANGELOG.md index 70667575..eef04a4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,13 @@ This format follows [Keep a Changelog](https://keepachangelog.com/) and adheres contextual information icons, and totals for additive metrics. Model deployment falls back to the request model when a deployment attribute is absent, while the response model remains independently visible. +- **Observe now scales large agent inventories without re-querying Azure Monitor + for every table interaction.** Discovery and normalized aggregates use shared + single-flight caches with stale-while-revalidate refresh, Azure Monitor batches + run with bounded concurrency, and Agents, Models, Tools, Runs, and Coverage add + server-side search, sorting, and pagination. Responses expose stage-level timing + through diagnostics and the `Server-Timing` header. Status badges also use + stronger borders, filled surfaces, and larger type in both themes. - **Observability readiness only reports what it can actually verify.** Multi-turn coverage is treated as a dataset property and inferred solely from conversation rows; rubric evaluators count as ready only when they are both declared and diff --git a/docs/observe.md b/docs/observe.md index 94bb2bb9..12b07619 100644 --- a/docs/observe.md +++ b/docs/observe.md @@ -73,6 +73,28 @@ filters, **Tools** accepts `tool_name` and **Runs** accepts `run_key`. Both only narrow results; blank values are rejected, and values are escaped before they reach telemetry queries. +### Large inventories + +Observe keeps large inventories responsive by separating telemetry collection +from table navigation. One bounded Azure Monitor collection builds a normalized +aggregate of at most 5,000 rows. Agents, Models, Tools, Runs, and Coverage then +search, sort, and paginate that aggregate through the Observe API, returning at +most 100 rows per page. Changing the page, search text, sort column, or sort +direction does not issue another Azure Monitor query. + +Discovery is cached for 15 minutes. Normalized aggregates stay fresh for two +minutes and can be served stale for up to five additional minutes while one +background refresh runs. Single-flight coordination prevents concurrent requests +for the same scope from duplicating discovery or telemetry work, and source +batches use bounded concurrency. An explicit refresh bypasses reusable view data. +Protected trace content and delegated user-level results remain excluded from +shared caches. + +Every response reports discovery, Azure Monitor, normalization, and total +durations. The HTTP endpoint exposes the same stages in `Server-Timing`, together +with cache hit, miss, bypass, or stale state, so operators can distinguish slow +discovery from a slow telemetry query. + ## Allocate declared billed totals The Cost view is an operational allocation of totals supplied by an operator. @@ -583,7 +605,10 @@ and a visually hidden data ``). Filters stay compact and visually subordinate to the summary. The Agents, Models, Tools, Runs, Costs, Attribution, and Coverage views are clear -drill-down tables. +drill-down tables. Status and classification badges use filled semantic surfaces, +high-contrast borders, 12-pixel bold text, and the same minimum height as nearby +controls in both themes, avoiding the thin low-resolution treatment used +previously. ### Intentional states diff --git a/specs/011-deploy-hosted-cockpit/contracts/observe-api.openapi.yaml b/specs/011-deploy-hosted-cockpit/contracts/observe-api.openapi.yaml index 4f283460..1f69e147 100644 --- a/specs/011-deploy-hosted-cockpit/contracts/observe-api.openapi.yaml +++ b/specs/011-deploy-hosted-cockpit/contracts/observe-api.openapi.yaml @@ -244,12 +244,32 @@ components: required: [view, filters] properties: view: - enum: [overview, agents, models, coverage] + enum: [overview, agents, models, coverage, tools, runs, cost] filters: $ref: "#/components/schemas/ObserveFilters" refresh: type: boolean default: false + page: + type: integer + minimum: 1 + maximum: 1000 + default: 1 + page_size: + type: integer + minimum: 1 + maximum: 100 + default: 50 + search: + type: [string, "null"] + maxLength: 200 + sort_by: + type: [string, "null"] + maxLength: 64 + pattern: "^[a-z][a-z0-9_]*$" + sort_direction: + enum: [asc, desc] + default: desc AgentDetailRequest: type: object additionalProperties: false @@ -276,6 +296,30 @@ components: type: [string, "null"] model: type: [string, "null"] + tool_name: + type: [string, "null"] + maxLength: 256 + run_key: + type: [string, "null"] + maxLength: 256 + cost_period_id: + type: [string, "null"] + maxLength: 64 + cost_breakdown: + type: [string, "null"] + enum: [agents, tools, runs, null] + cost_component_id: + type: [string, "null"] + maxLength: 64 + cost_agent_key: + type: [string, "null"] + maxLength: 512 + user_filter_token: + type: [string, "null"] + maxLength: 1024 + department_filter_token: + type: [string, "null"] + maxLength: 1024 start: type: string format: date-time @@ -286,11 +330,11 @@ components: type: object additionalProperties: false required: - [view, data, trends, portal_links, coverage, partial_failures, diagnostics, - refreshed_at, cache_status] + [view, data, coverage, partial_failures, diagnostics, bounds, refreshed_at, + cache_status] properties: view: - enum: [overview, agents, models, coverage] + enum: [overview, agents, models, coverage, tools, runs, cost] data: oneOf: - type: object @@ -306,11 +350,15 @@ components: $ref: "#/components/schemas/QuerySourceFailure" diagnostics: $ref: "#/components/schemas/QueryDiagnostics" + bounds: + oneOf: + - $ref: "#/components/schemas/ResultBounds" + - type: "null" refreshed_at: type: string format: date-time cache_status: - enum: [hit, miss, bypass] + enum: [hit, miss, bypass, stale] AgentDetailResponse: type: object additionalProperties: false @@ -377,6 +425,9 @@ components: - started_at - completed_at - duration_ms + - discovery_duration_ms + - query_duration_ms + - normalization_duration_ms - source_count - successful_sources - partial_sources @@ -392,10 +443,18 @@ components: duration_ms: type: integer minimum: 0 + discovery_duration_ms: + type: integer + minimum: 0 + query_duration_ms: + type: integer + minimum: 0 + normalization_duration_ms: + type: integer + minimum: 0 source_count: type: integer minimum: 0 - maximum: 10 successful_sources: type: integer minimum: 0 @@ -407,6 +466,41 @@ components: minimum: 0 cache_status: enum: [hit, miss, bypass] + ResultBounds: + type: object + additionalProperties: false + required: + - rows_shown + - rows_total_in_scope + - truncated + - page + - page_size + - has_previous_page + - has_next_page + properties: + rows_shown: + type: integer + minimum: 0 + maximum: 5000 + rows_total_in_scope: + type: [integer, "null"] + minimum: 0 + truncated: + type: boolean + default: false + page: + type: [integer, "null"] + minimum: 1 + page_size: + type: [integer, "null"] + minimum: 1 + maximum: 100 + has_previous_page: + type: boolean + default: false + has_next_page: + type: boolean + default: false SourceFailure: type: object additionalProperties: false diff --git a/src/agentops/agent/cockpit.py b/src/agentops/agent/cockpit.py index 04119739..d5e9a84f 100644 --- a/src/agentops/agent/cockpit.py +++ b/src/agentops/agent/cockpit.py @@ -20,6 +20,7 @@ import re import shutil import subprocess +import time from dataclasses import dataclass from importlib.resources import files as _pkg_files from pathlib import Path @@ -5861,15 +5862,33 @@ async def _api_observe_query( filters = payload.filters if effective_scope is not None: filters.validate_scope(ObserveScope.model_validate(effective_scope)) - return JSONResponse( - await _service_call( - "query", - view=payload.view, - filters=filters.model_dump(mode="json"), - refresh=payload.refresh, - user_context=user_context, - ) + request_started = time.perf_counter() + body = await _service_call( + "query", + view=payload.view, + filters=filters.model_dump(mode="json"), + refresh=payload.refresh, + page=payload.page, + page_size=payload.page_size, + search=payload.search, + sort_by=payload.sort_by, + sort_direction=payload.sort_direction, + user_context=user_context, ) + total_ms = (time.perf_counter() - request_started) * 1000 + diagnostics = body.get("diagnostics", {}) if isinstance(body, dict) else {} + cache_status = body.get("cache_status", "miss") if isinstance(body, dict) else "miss" + timings = [f'total;dur={total_ms:.1f}', f'cache;desc="{cache_status}"'] + if cache_status not in {"hit", "stale"} and isinstance(diagnostics, dict): + for name, field in ( + ("discovery", "discovery_duration_ms"), + ("monitor", "query_duration_ms"), + ("normalize", "normalization_duration_ms"), + ): + duration = diagnostics.get(field) + if isinstance(duration, (int, float)): + timings.append(f"{name};dur={max(float(duration), 0):.1f}") + return JSONResponse(body, headers={"Server-Timing": ", ".join(timings)}) @app.post("/api/observe/attribution") async def _api_observe_attribution( diff --git a/src/agentops/agent/observe/adapters.py b/src/agentops/agent/observe/adapters.py index 71c01f67..1707e347 100644 --- a/src/agentops/agent/observe/adapters.py +++ b/src/agentops/agent/observe/adapters.py @@ -960,10 +960,14 @@ def __init__( credential: Any, source_timeout_seconds: int = SOURCE_TIMEOUT_SECONDS, request_deadline_seconds: int = DEFAULT_REQUEST_DEADLINE_SECONDS, + max_concurrent_batches: int = 4, clock: Callable[[], float] = time.monotonic, ) -> None: + if max_concurrent_batches < 1: + raise ValueError("max_concurrent_batches must be positive") self._source_timeout_seconds = source_timeout_seconds self._request_deadline_seconds = request_deadline_seconds + self._max_concurrent_batches = max_concurrent_batches self._clock = clock self._logs_client = _LogsQueryAdapter(credential=credential) @@ -973,8 +977,10 @@ async def _run( build_query: Callable[[TelemetrySource], str], ) -> list[SourceResult]: queryable = [source for source in sources if source.workspace_id] - results: list[SourceResult] = [] - for chunk in _chunked(queryable, MAX_SOURCES_PER_BATCH): + chunks = list(_chunked(queryable, MAX_SOURCES_PER_BATCH)) + semaphore = asyncio.Semaphore(self._max_concurrent_batches) + + async def run_chunk(chunk: Sequence[TelemetrySource]) -> list[SourceResult]: queries = [ SourceQuery( source_id=source.source_id, @@ -985,17 +991,18 @@ async def _run( if source.workspace_id ] if not queries: - continue - results.extend( - await execute_source_batch( + return [] + async with semaphore: + return await execute_source_batch( queries, client=self._logs_client, source_timeout_seconds=self._source_timeout_seconds, request_deadline_seconds=self._request_deadline_seconds, clock=self._clock, ) - ) - return results + + chunk_results = await asyncio.gather(*(run_chunk(chunk) for chunk in chunks)) + return [result for batch in chunk_results for result in batch] async def query( self, diff --git a/src/agentops/agent/observe/cache.py b/src/agentops/agent/observe/cache.py index 53b65117..b1484574 100644 --- a/src/agentops/agent/observe/cache.py +++ b/src/agentops/agent/observe/cache.py @@ -6,7 +6,7 @@ from collections import OrderedDict from dataclasses import dataclass from threading import RLock -from typing import Any, Callable, Generic, Hashable, TypeVar +from typing import Any, Callable, Generic, Hashable, Literal, TypeVar class SensitiveValueError(ValueError): @@ -49,6 +49,14 @@ class _Entry(Generic[V]): value: V +@dataclass(frozen=True) +class CacheLookup(Generic[V]): + """One cache lookup, including whether an expired value is still usable.""" + + state: Literal["fresh", "stale", "miss"] + value: V | None = None + + class ObserveCache(Generic[K, V]): """Thread-safe TTL/LRU cache that refuses sensitive Observe values.""" @@ -70,17 +78,32 @@ def __init__( self._lock = RLock() def get(self, key: K, *, bypass: bool = False) -> V | None: + return self.lookup(key, bypass=bypass).value + + def lookup( + self, + key: K, + *, + bypass: bool = False, + max_stale_seconds: float = 0, + ) -> CacheLookup[V]: + """Return a fresh or explicitly allowed stale value for *key*.""" if bypass: - return None + return CacheLookup(state="miss") + if max_stale_seconds < 0: + raise ValueError("max_stale_seconds cannot be negative") with self._lock: entry = self._entries.get(key) if entry is None: - return None - if self._clock() - entry.created_at >= self._ttl_seconds: + return CacheLookup(state="miss") + age = self._clock() - entry.created_at + if age >= self._ttl_seconds + max_stale_seconds: del self._entries[key] - return None + return CacheLookup(state="miss") self._entries.move_to_end(key) - return entry.value + if age >= self._ttl_seconds: + return CacheLookup(state="stale", value=entry.value) + return CacheLookup(state="fresh", value=entry.value) def set(self, key: K, value: V) -> None: if _contains_sensitive_value(value): diff --git a/src/agentops/agent/observe/facade.py b/src/agentops/agent/observe/facade.py index 1656ec9e..f7645e76 100644 --- a/src/agentops/agent/observe/facade.py +++ b/src/agentops/agent/observe/facade.py @@ -60,7 +60,13 @@ classify_appgenai_content_result, execute_source_batch, ) -from agentops.agent.observe.service import CACHE_TTL_SECONDS, ObserveResult, ObserveService, View +from agentops.agent.observe.service import ( + CACHE_TTL_SECONDS, + INVENTORY_CACHE_TTL_SECONDS, + ObserveResult, + ObserveService, + View, +) from agentops.agent.observe.ui import build_azure_resource_portal_url from agentops.core.attribution import ( AttributionConfigurationLoadResult, @@ -411,6 +417,9 @@ def __init__( credential=credential, clock=monotonic_clock ) self._cache = cache or ObserveCache(ttl_seconds=CACHE_TTL_SECONDS) + self._inventory_cache: ObserveCache = ObserveCache( + ttl_seconds=INVENTORY_CACHE_TTL_SECONDS + ) runtime_identity = ( cast(str, uami_client_id) if auth_mode == "hosted" @@ -423,6 +432,8 @@ def __init__( runtime=self._runtime, clock=clock, cache=self._cache, + inventory_cache=self._inventory_cache, + monotonic_clock=monotonic_clock, ) # -- discover ----------------------------------------------------- @@ -442,6 +453,11 @@ async def query( view: str, filters: Mapping[str, Any], refresh: bool = False, + page: int = 1, + page_size: int = 50, + search: str | None = None, + sort_by: str | None = None, + sort_direction: Literal["asc", "desc"] = "desc", user_context: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """Return one normalized, coverage-annotated Observe view. @@ -484,7 +500,15 @@ async def query( native_view = cast(View, view) result = await self._service.query_view( - self._scope, filter_state, view=native_view, refresh=refresh + self._scope, + filter_state, + view=native_view, + refresh=refresh, + page=page, + page_size=page_size, + search=search, + sort_by=sort_by, + sort_direction=sort_direction, ) return _serialize_observe_result(result) diff --git a/src/agentops/agent/observe/service.py b/src/agentops/agent/observe/service.py index e3813f3b..16166f3d 100644 --- a/src/agentops/agent/observe/service.py +++ b/src/agentops/agent/observe/service.py @@ -19,7 +19,10 @@ import asyncio import json -from dataclasses import dataclass +import logging +import time +from collections.abc import Callable, Coroutine, Hashable +from dataclasses import dataclass, replace from datetime import datetime, timedelta from decimal import Decimal from typing import Any, Literal, Mapping, Protocol, Sequence @@ -93,6 +96,10 @@ #: Identity/scope/filter cache entries stay fresh for two minutes (T046). CACHE_TTL_SECONDS = 120.0 +INVENTORY_CACHE_TTL_SECONDS = 15 * 60.0 +VIEW_STALE_TTL_SECONDS = 5 * 60.0 + +logger = logging.getLogger(__name__) View = Literal["overview", "agents", "models", "tools", "runs", "cost"] @@ -184,7 +191,7 @@ class ObserveResult: partial_failures: list[PartialFailure] bounds: ResultBounds | None refreshed_at: datetime - cache_status: Literal["hit", "miss", "bypass"] + cache_status: Literal["hit", "miss", "bypass", "stale"] @dataclass(frozen=True) @@ -1171,6 +1178,187 @@ def _bound_view_data(view: View, data: Sequence[Any]) -> list[Any]: )[:MAX_ROWS_PER_QUERY] +_VIEW_SORT_FIELDS: dict[str, frozenset[str]] = { + "agents": frozenset( + { + "agent_name", + "agent_id", + "source_kind", + "model", + "last_seen", + "invocations", + "failures", + "failure_rate", + "p95_latency_ms", + "input_tokens", + "output_tokens", + "total_tokens", + "cache_read_tokens", + "cache_write_tokens", + "reasoning_tokens", + } + ), + "models": frozenset( + { + "model", + "deployment", + "last_seen", + "requests", + "failures", + "failure_rate", + "p95_latency_ms", + "input_tokens", + "output_tokens", + "total_tokens", + "cache_read_tokens", + "cache_write_tokens", + "reasoning_tokens", + } + ), + "tools": frozenset( + { + "tool_name", + "agent_name", + "source_id", + "source_kind", + "last_seen", + "invocations", + "failures", + "p95_latency_ms", + } + ), + "runs": frozenset( + { + "run_key", + "run_key_kind", + "agent_name", + "source_id", + "source_kind", + "started_at", + "last_activity_at", + "duration_ms", + "status", + "turns", + "tool_invocations", + "input_tokens", + "output_tokens", + "total_tokens", + "cache_read_tokens", + "cache_write_tokens", + "reasoning_tokens", + } + ), +} +_DEFAULT_VIEW_SORT = { + "agents": "invocations", + "models": "requests", + "tools": "invocations", + "runs": "last_activity_at", +} + + +def _row_value(row: Any, field: str) -> Any: + if field == "total_tokens": + input_tokens = _row_value(row, "input_tokens") + output_tokens = _row_value(row, "output_tokens") + if input_tokens is None and output_tokens is None: + return None + return (input_tokens or 0) + (output_tokens or 0) + if field == "failure_rate": + failures = _row_value(row, "failures") + total = _row_value(row, "invocations") + if total is None: + total = _row_value(row, "requests") + if failures is None or not total: + return None + return failures / total + if isinstance(row, Mapping): + return row.get(field) + return getattr(row, field, None) + + +def _sortable_value(value: Any) -> Any: + if isinstance(value, datetime): + return value.timestamp() + if isinstance(value, Decimal): + return float(value) + if isinstance(value, str): + return value.casefold() + return value + + +def _searchable_row(row: Any) -> str: + if hasattr(row, "model_dump"): + value = row.model_dump(mode="json") + elif isinstance(row, Mapping): + value = dict(row) + else: + value = vars(row) + return json.dumps(value, sort_keys=True, default=str).casefold() + + +def _page_observe_result( + result: ObserveResult, + *, + page: int, + page_size: int, + search: str | None, + sort_by: str | None, + sort_direction: Literal["asc", "desc"], +) -> ObserveResult: + """Sort, filter, and page one cached aggregate without re-querying Azure.""" + if result.view not in _VIEW_SORT_FIELDS or not isinstance(result.data, list): + return result + allowed_fields = _VIEW_SORT_FIELDS[result.view] + field = sort_by or _DEFAULT_VIEW_SORT[result.view] + if field not in allowed_fields: + raise ValueError(f"unsupported sort field {field!r} for {result.view}") + + rows = list(result.data) + normalized_search = (search or "").strip().casefold() + if normalized_search: + rows = [row for row in rows if normalized_search in _searchable_row(row)] + + present: list[tuple[int, Any, Any]] = [] + missing: list[tuple[int, Any]] = [] + for index, row in enumerate(rows): + value = _row_value(row, field) + if value is None: + missing.append((index, row)) + else: + present.append((index, row, _sortable_value(value))) + present.sort( + key=lambda item: (item[2], item[0]), + reverse=sort_direction == "desc", + ) + ordered = [item[1] for item in present] + [item[1] for item in missing] + + start = (page - 1) * page_size + end = start + page_size + visible = ordered[start:end] + source_bounds = result.bounds + hard_truncated = bool(source_bounds and source_bounds.truncated) + if normalized_search and hard_truncated: + total: int | None = None + elif normalized_search: + total = len(ordered) + elif source_bounds is not None: + total = source_bounds.rows_total_in_scope + else: + total = len(ordered) + has_next = end < len(ordered) + bounds = ResultBounds( + rows_shown=len(visible), + rows_total_in_scope=total, + truncated=hard_truncated, + page=page, + page_size=page_size, + has_previous_page=page > 1, + has_next_page=has_next, + ) + return replace(result, data=visible, bounds=bounds) + + def _build_partial_failures(results: Sequence[SourceResult]) -> list[PartialFailure]: """Summarize every non-``success`` source outcome as a safe partial failure (T061). @@ -1779,12 +1967,79 @@ def __init__( runtime: RuntimeContext, clock: Clock, cache: ObserveCache, + inventory_cache: ObserveCache | None = None, + monotonic_clock: Callable[[], float] = time.monotonic, ) -> None: self._discovery_client = discovery_client self._query_client = query_client self._runtime = runtime self._clock = clock self._cache = cache + self._inventory_cache = inventory_cache or cache + self._monotonic_clock = monotonic_clock + self._inflight: dict[Hashable, asyncio.Task[Any]] = {} + self._inflight_lock = asyncio.Lock() + self._background_tasks: set[asyncio.Task[Any]] = set() + + async def _run_coalesced( + self, + key: Hashable, + operation: Callable[[], Coroutine[Any, Any, Any]], + ) -> tuple[Any, bool]: + """Run one operation per cache key and let concurrent callers share it.""" + async with self._inflight_lock: + task = self._inflight.get(key) + owner = task is None + if task is None: + task = asyncio.create_task(operation()) + self._inflight[key] = task + + def remove_inflight(completed: asyncio.Task[Any]) -> None: + if self._inflight.get(key) is completed: + self._inflight.pop(key, None) + + task.add_done_callback(remove_inflight) + return await asyncio.shield(task), owner + + def _schedule_refresh( + self, + key: Hashable, + operation: Callable[[], Coroutine[Any, Any, Any]], + ) -> None: + """Refresh a stale aggregate without delaying the current response.""" + task = asyncio.create_task(self._run_coalesced(key, operation)) + self._background_tasks.add(task) + + def finish(completed: asyncio.Task[Any]) -> None: + self._background_tasks.discard(completed) + if completed.cancelled(): + return + error = completed.exception() + if error is not None: + logger.warning( + "Observe background refresh failed for key %r", + key, + exc_info=(type(error), error, error.__traceback__), + ) + + task.add_done_callback(finish) + + def _cached_result( + self, + cached: _CachedView, + *, + cache_status: Literal["hit", "stale"], + ) -> ObserveResult: + return ObserveResult( + view=cached.view, + data=cached.data, + coverage=cached.coverage, + diagnostics=cached.diagnostics, + partial_failures=cached.partial_failures, + bounds=cached.bounds, + refreshed_at=cached.refreshed_at, + cache_status=cache_status, + ) async def get_inventory( self, scope: ObserveScope, *, refresh: bool = False @@ -1792,11 +2047,16 @@ async def get_inventory( """Discover (or return the cached) resource inventory for *scope*.""" identity = _identity_key(self._runtime) key = _cache_key(identity, scope, "discovery", None) - cached = self._cache.get(key, bypass=refresh) + cached = self._inventory_cache.get(key, bypass=refresh) if cached is not None: return cached - inventory = await self._discovery_client.discover(scope) - self._cache.set(key, inventory) + + async def discover() -> ResourceInventory: + inventory = await self._discovery_client.discover(scope) + self._inventory_cache.set(key, inventory) + return inventory + + inventory, _owner = await self._run_coalesced(key, discover) return inventory async def query_view( @@ -1806,41 +2066,109 @@ async def query_view( *, view: View, refresh: bool = False, + page: int = 1, + page_size: int = 50, + search: str | None = None, + sort_by: str | None = None, + sort_direction: Literal["asc", "desc"] = "desc", ) -> ObserveResult: """Return the normalized, coverage-annotated response for *view*.""" filters.validate_scope(scope) identity = _identity_key(self._runtime) key = _cache_key(identity, scope, view, filters) - cached = self._cache.get(key, bypass=refresh) - if cached is not None: - return ObserveResult( - view=cached.view, - data=cached.data, - coverage=cached.coverage, - diagnostics=cached.diagnostics, - partial_failures=cached.partial_failures, - bounds=cached.bounds, - refreshed_at=cached.refreshed_at, - cache_status="hit", + cached = self._cache.lookup( + key, + bypass=refresh, + max_stale_seconds=VIEW_STALE_TTL_SECONDS, + ) + if cached.state == "fresh" and cached.value is not None: + result = self._cached_result(cached.value, cache_status="hit") + return _page_observe_result( + result, + page=page, + page_size=page_size, + search=search, + sort_by=sort_by, + sort_direction=sort_direction, + ) + if cached.state == "stale" and cached.value is not None: + self._schedule_refresh( + key, + lambda: self._query_view_uncached( + scope, + filters, + view=view, + key=key, + cache_status="miss", + ), + ) + result = self._cached_result(cached.value, cache_status="stale") + return _page_observe_result( + result, + page=page, + page_size=page_size, + search=search, + sort_by=sort_by, + sort_direction=sort_direction, ) + result, owner = await self._run_coalesced( + key, + lambda: self._query_view_uncached( + scope, + filters, + view=view, + key=key, + cache_status="bypass" if refresh else "miss", + ), + ) + if owner: + response = result + else: + response = replace(result, cache_status="hit") + return _page_observe_result( + response, + page=page, + page_size=page_size, + search=search, + sort_by=sort_by, + sort_direction=sort_direction, + ) + + async def _query_view_uncached( + self, + scope: ObserveScope, + filters: ObserveFilterState, + *, + view: View, + key: tuple[Any, ...], + cache_status: Literal["miss", "bypass"], + ) -> ObserveResult: + request_started_at = self._clock() + request_started = self._monotonic_clock() + discovery_started = self._monotonic_clock() # Refresh telemetry without repeating the slower control-plane discovery. # Inventory has its own cache and explicit discover endpoint for forced refreshes. inventory = await self.get_inventory(scope) + discovery_duration_ms = int( + (self._monotonic_clock() - discovery_started) * 1000 + ) available_sources = [ source for source in inventory.telemetry_sources if source.state == "available" ] - started_at = self._clock() + query_started = self._monotonic_clock() source_results = list( await self._query_client.query(available_sources, filters, view=view) ) - completed_at = self._clock() + query_duration_ms = int((self._monotonic_clock() - query_started) * 1000) + normalization_started = self._monotonic_clock() + normalization_refreshed_at = self._clock() coverage = self._discovery_coverage( - inventory.telemetry_sources, refreshed_at=completed_at + inventory.telemetry_sources, refreshed_at=normalization_refreshed_at ) data, query_coverage = self._normalize_view( view, @@ -1848,7 +2176,7 @@ async def query_view( inventory.telemetry_sources, inventory=inventory, window_end=filters.end, - refreshed_at=completed_at, + refreshed_at=normalization_refreshed_at, ) if view in ("agents", "models", "tools", "runs"): data = _bound_view_data(view, data) @@ -1858,12 +2186,21 @@ async def query_view( if view in ("agents", "models", "tools", "runs") else None ) + completed_at = self._clock() + normalization_duration_ms = int( + (self._monotonic_clock() - normalization_started) * 1000 + ) + duration_ms = int((self._monotonic_clock() - request_started) * 1000) diagnostics = self._build_diagnostics( source_results, - started_at=started_at, + started_at=request_started_at, completed_at=completed_at, - cache_status="bypass" if refresh else "miss", + cache_status=cache_status, + duration_ms=duration_ms, + discovery_duration_ms=discovery_duration_ms, + query_duration_ms=query_duration_ms, + normalization_duration_ms=normalization_duration_ms, ) partial_failures = _build_partial_failures(source_results) @@ -1887,7 +2224,7 @@ async def query_view( partial_failures=partial_failures, bounds=bounds, refreshed_at=completed_at, - cache_status="bypass" if refresh else "miss", + cache_status=cache_status, ) async def query_attribution( @@ -3738,6 +4075,10 @@ def _build_diagnostics( started_at: datetime, completed_at: datetime, cache_status: Literal["miss", "bypass"], + duration_ms: int | None = None, + discovery_duration_ms: int = 0, + query_duration_ms: int = 0, + normalization_duration_ms: int = 0, ) -> QueryDiagnostics: successful = sum(1 for result in results if result.status == "success") partial = sum(1 for result in results if result.status == "partial") @@ -3746,11 +4087,15 @@ def _build_diagnostics( for result in results if result.status in ("timeout", "throttled", "error") ) - duration_ms = max(int((completed_at - started_at).total_seconds() * 1000), 0) + if duration_ms is None: + duration_ms = int((completed_at - started_at).total_seconds() * 1000) return QueryDiagnostics( started_at=started_at, completed_at=completed_at, - duration_ms=duration_ms, + duration_ms=max(duration_ms, 0), + discovery_duration_ms=max(discovery_duration_ms, 0), + query_duration_ms=max(query_duration_ms, 0), + normalization_duration_ms=max(normalization_duration_ms, 0), source_count=len(results), successful_sources=successful, partial_sources=partial, diff --git a/src/agentops/agent/observe/ui.py b/src/agentops/agent/observe/ui.py index 8ef78b69..302d1659 100644 --- a/src/agentops/agent/observe/ui.py +++ b/src/agentops/agent/observe/ui.py @@ -2865,12 +2865,42 @@ def render_trace_detail_shell( background: color-mix(in srgb, var(--observe-accent) 26%, transparent); } .observe-refresh-button:hover { border-color: var(--observe-border-strong); } +.observe-refresh-button:disabled { + cursor: not-allowed; + opacity: 0.45; +} .observe-apply-button:focus-visible, .observe-refresh-button:focus-visible { outline: 2px solid var(--observe-accent); outline-offset: 2px; } .observe-refresh-status { color: var(--observe-muted); font-size: 12px; } +.observe-page-controls { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 8px; +} +.observe-page-controls input, +.observe-page-controls select { + box-sizing: border-box; + min-height: 32px; + border: 1px solid var(--observe-border); + border-radius: 8px; + background: var(--observe-card-bg); + color: var(--observe-fg); + font: inherit; + font-size: 13px; + padding: 5px 9px; +} +.observe-page-controls input { min-width: 220px; } +.observe-page-status { + color: var(--observe-muted); + font-size: 12px; + min-width: 48px; + text-align: center; +} .observe-scope { margin: 0 0 10px; font-size: 12px; color: var(--observe-muted); } .observe-attribution-summary { @@ -3089,12 +3119,43 @@ def render_trace_detail_shell( .observe-badge { display: inline-flex; align-items: center; + justify-content: center; + min-height: 24px; + box-sizing: border-box; border-radius: 999px; - padding: 1px 8px; - font-size: 11px; - font-weight: 600; - line-height: 1.6; - border: 1px solid currentColor; + padding: 3px 9px; + font-size: 12px; + font-weight: 700; + line-height: 1.25; + letter-spacing: 0.01em; + white-space: nowrap; + border: 1px solid var(--observe-border-strong); + background: var(--observe-card-hi); +} +.observe-badge.observe-tone-ok { + border-color: color-mix(in srgb, var(--observe-ok) 52%, var(--observe-border)); + background: color-mix(in srgb, var(--observe-ok) 16%, var(--observe-card-bg)); + color: var(--observe-ok); +} +.observe-badge.observe-tone-warn { + border-color: color-mix(in srgb, var(--observe-warn) 52%, var(--observe-border)); + background: color-mix(in srgb, var(--observe-warn) 14%, var(--observe-card-bg)); + color: var(--observe-warn); +} +.observe-badge.observe-tone-crit { + border-color: color-mix(in srgb, var(--observe-crit) 52%, var(--observe-border)); + background: color-mix(in srgb, var(--observe-crit) 14%, var(--observe-card-bg)); + color: var(--observe-crit); +} +.observe-badge.observe-tone-info { + border-color: color-mix(in srgb, var(--observe-accent) 52%, var(--observe-border)); + background: color-mix(in srgb, var(--observe-accent) 14%, var(--observe-card-bg)); + color: var(--observe-accent); +} +.observe-badge.observe-tone-muted { + border-color: var(--observe-border-strong); + background: color-mix(in srgb, var(--observe-muted) 10%, var(--observe-card-bg)); + color: var(--observe-muted); } .observe-tone-ok { color: var(--observe-ok); } .observe-tone-warn { color: var(--observe-warn); } @@ -3230,6 +3291,7 @@ def render_trace_detail_shell( var ATTRIBUTION_COST_UNAVAILABLE = "Cost attribution is unavailable. Configure a valid cost model and allocatable cost before selecting Cost."; var AUTO_REFRESH_MS = 300000; // five minutes var DEFAULT_RANGE_MS = 24 * 60 * 60 * 1000; // trailing 24 hours + var CACHE_WINDOW_MS = 60 * 1000; // align default windows across browser sessions // Mirrors MAX_TREND_POINTS in ui.py: even though the backend is expected // to already bound each trend series (T053), the client re-bounds // defensively so a chart never renders unbounded markup regardless of @@ -3239,6 +3301,39 @@ def render_trace_detail_shell( // from contracts/observe-api.openapi.yaml (mirrors OBSERVE_VIEW_WIRE_NAMES // in ui.py -- the internal "usage" id is spelled "models" on the wire). var VIEW_WIRE_NAMES = { overview: "overview", agents: "agents", usage: "models", tools: "tools", runs: "runs" }; + var SERVER_SORT_KEYS = { + "observe-agents-table": { + "Agent": "agent_name", "Source": "source_kind", "Model": "model", + "Last seen": "last_seen", "Invocations": "invocations", + "Failure rate": "failure_rate", "p95 latency": "p95_latency_ms", + "Input tokens": "input_tokens", "Output tokens": "output_tokens", + "Total tokens": "total_tokens", "Cache read": "cache_read_tokens", + "Cache write": "cache_write_tokens", "Reasoning": "reasoning_tokens" + }, + "observe-usage-table": { + "Model": "model", "Deployment": "deployment", "Requests": "requests", + "Failure rate": "failure_rate", "p95 latency": "p95_latency_ms", + "Input tokens": "input_tokens", "Output tokens": "output_tokens", + "Total tokens": "total_tokens", "Cache read": "cache_read_tokens", + "Cache write": "cache_write_tokens", "Reasoning": "reasoning_tokens", + "Last seen": "last_seen" + }, + "observe-tools-table": { + "Tool": "tool_name", "Agent": "agent_name", "Source": "source_id", + "Runtime": "source_kind", "Last seen": "last_seen", + "Invocations": "invocations", "Failures": "failures", + "p95 latency": "p95_latency_ms" + }, + "observe-runs-table": { + "Run key": "run_key", "Correlation": "run_key_kind", "Agent": "agent_name", + "Source": "source_id", "Runtime": "source_kind", "Started in range": "started_at", + "Duration in range": "duration_ms", "Status": "status", "Turns in range": "turns", + "Tool invocations": "tool_invocations", "Input tokens": "input_tokens", + "Output tokens": "output_tokens", "Total tokens": "total_tokens", + "Cache read": "cache_read_tokens", + "Cache write": "cache_write_tokens", "Reasoning": "reasoning_tokens" + } + }; // Best-effort, human-friendly labels for *documented* portal link keys // (mirrors _KNOWN_PORTAL_LABELS in ui.py). Any key not listed here still // renders (title-cased) rather than being dropped -- the "best-effort @@ -3257,6 +3352,11 @@ def render_trace_detail_shell( var draftFilters = {}; var appliedFilters = {}; var currentView = "overview"; + var currentPage = 1; + var currentPageSize = 50; + var currentSearch = ""; + var currentSortBy = ""; + var currentSortDirection = "desc"; var requestToken = 0; var activeController = null; var refreshTimer = null; @@ -3299,11 +3399,22 @@ def render_trace_detail_shell( if (currentView === "cost" && !document.getElementById("cost")) { currentView = "overview"; } + currentPage = Math.max(1, parseInt(params.get("page") || "1", 10) || 1); + currentPageSize = [25, 50, 100].indexOf(parseInt(params.get("page_size") || "50", 10)) >= 0 + ? parseInt(params.get("page_size") || "50", 10) + : 50; + currentSearch = String(params.get("search") || "").slice(0, 200); + currentSortBy = String(params.get("sort_by") || "").slice(0, 64); + currentSortDirection = params.get("sort_direction") === "asc" ? "asc" : "desc"; if (!applied.start || !applied.end) { - var end = new Date(); - var start = new Date(end.getTime() - DEFAULT_RANGE_MS); - applied.start = applied.start || start.toISOString(); - applied.end = applied.end || end.toISOString(); + var end = applied.end + ? new Date(applied.end) + : new Date(Math.floor(Date.now() / CACHE_WINDOW_MS) * CACHE_WINDOW_MS); + var start = applied.start + ? new Date(applied.start) + : new Date(end.getTime() - DEFAULT_RANGE_MS); + applied.start = start.toISOString(); + applied.end = end.toISOString(); } return applied; } @@ -3330,6 +3441,13 @@ def render_trace_detail_shell( }); } params.set("view", currentView); + if (isPagedView(currentView)) { + params.set("page", String(currentPage)); + params.set("page_size", String(currentPageSize)); + if (currentSearch) params.set("search", currentSearch); + if (currentSortBy) params.set("sort_by", currentSortBy); + params.set("sort_direction", currentSortDirection); + } params.set("theme", document.documentElement.getAttribute("data-theme") || "dark"); return window.location.pathname + "?" + params.toString(); } @@ -3812,13 +3930,27 @@ def render_trace_detail_shell( var label = header.dataset.label || String(header.textContent || "").trim() || "Column " + (columnIndex + 1); var helpText = header.dataset.help || ""; + var serverSortKey = header.dataset.serverSortKey || ""; clearChildren(header); - header.setAttribute("aria-sort", "none"); + var activeDirection = serverSortKey && currentSortBy === serverSortKey + ? (currentSortDirection === "asc" ? "ascending" : "descending") + : "none"; + header.setAttribute("aria-sort", activeDirection); var button = makeEl("button", "observe-sort-button", label); button.type = "button"; button.title = "Sort by " + label; button.setAttribute("aria-label", "Sort by " + label); button.addEventListener("click", function () { + if (serverSortKey) { + currentSortDirection = currentSortBy === serverSortKey && currentSortDirection === "asc" + ? "desc" + : "asc"; + currentSortBy = serverSortKey; + currentPage = 1; + syncUrl(); + fetchObserveData(false); + return; + } body.querySelectorAll("[data-observe-drilldown-row]").forEach(function (detailRow) { detailRow.remove(); }); @@ -3889,6 +4021,7 @@ def render_trace_detail_shell( table.setAttribute("aria-label", ariaLabel); var thead = document.createElement("thead"); var headRow = document.createElement("tr"); + var sortKeys = SERVER_SORT_KEYS[className] || {}; columns.forEach(function (column) { var definition = typeof column === "string" ? { label: column } : column; var th = makeEl("th", null, definition.label); @@ -3897,6 +4030,9 @@ def render_trace_detail_shell( if (definition.help) { th.dataset.help = definition.help; } + if (sortKeys[definition.label]) { + th.dataset.serverSortKey = sortKeys[definition.label]; + } headRow.appendChild(th); }); thead.appendChild(headRow); @@ -4142,12 +4278,110 @@ def render_trace_detail_shell( function boundsNoticeNode(bounds, rowsShown) { var total = bounds && bounds.rows_total_in_scope; - var text = total === null || total === undefined - ? "Showing " + rowsShown + " rows." - : "Showing " + rowsShown + " of " + total + " rows in scope."; + var page = bounds && bounds.page ? bounds.page : currentPage; + var pageSize = bounds && bounds.page_size ? bounds.page_size : currentPageSize; + var first = rowsShown ? ((page - 1) * pageSize) + 1 : 0; + var last = rowsShown ? first + rowsShown - 1 : 0; + var text; + if (!rowsShown && page > 1) { + text = "No rows are available on page " + page + "."; + } else if (bounds && bounds.truncated) { + text = "Showing rows " + first + "\u2013" + last + " from the highest-ranked results"; + text += total === null || total === undefined + ? "." + : " (" + total + " rows in scope)."; + } else { + text = total === null || total === undefined + ? "Showing rows " + first + "\u2013" + last + "." + : "Showing rows " + first + "\u2013" + last + " of " + total + "."; + } return makeEl("p", "observe-hint observe-bounds-notice", text); } + function isPagedView(view) { + return ["agents", "usage", "tools", "runs"].indexOf(view) >= 0; + } + + function resetPaging(resetQuery) { + currentPage = 1; + if (resetQuery) { + currentSearch = ""; + currentSortBy = ""; + currentSortDirection = "desc"; + } + } + + function paginationToolbar(bounds) { + var toolbar = makeEl("form", "observe-page-controls"); + toolbar.setAttribute("role", "search"); + toolbar.addEventListener("submit", function (event) { + event.preventDefault(); + currentSearch = String(search.value || "").trim().slice(0, 200); + currentPageSize = parseInt(pageSize.value, 10) || 50; + currentPage = 1; + syncUrl(); + fetchObserveData(false); + }); + + var search = document.createElement("input"); + search.type = "search"; + search.value = currentSearch; + search.placeholder = "Search this view"; + search.setAttribute("aria-label", "Search this view"); + toolbar.appendChild(search); + + var pageSize = document.createElement("select"); + pageSize.setAttribute("aria-label", "Rows per page"); + [25, 50, 100].forEach(function (size) { + var option = document.createElement("option"); + option.value = String(size); + option.textContent = size + " rows"; + option.selected = size === currentPageSize; + pageSize.appendChild(option); + }); + toolbar.appendChild(pageSize); + toolbar.appendChild(makeEl("button", "observe-refresh-button", "Search")); + var clear = makeEl("button", "observe-refresh-button", "Clear"); + clear.type = "button"; + clear.disabled = !currentSearch; + clear.addEventListener("click", function () { + currentSearch = ""; + currentPage = 1; + syncUrl(); + fetchObserveData(false); + }); + toolbar.appendChild(clear); + + var previous = makeEl("button", "observe-refresh-button", "Previous"); + previous.type = "button"; + previous.disabled = !(bounds && bounds.has_previous_page); + previous.addEventListener("click", function () { + currentPage = Math.max(1, currentPage - 1); + syncUrl(); + fetchObserveData(false); + }); + toolbar.appendChild(previous); + + var status = makeEl( + "span", + "observe-page-status", + "Page " + String((bounds && bounds.page) || currentPage) + ); + status.setAttribute("aria-live", "polite"); + toolbar.appendChild(status); + + var next = makeEl("button", "observe-refresh-button", "Next"); + next.type = "button"; + next.disabled = !(bounds && bounds.has_next_page); + next.addEventListener("click", function () { + currentPage += 1; + syncUrl(); + fetchObserveData(false); + }); + toolbar.appendChild(next); + return toolbar; + } + function renderOverview(data, diagnostics) { var metrics = overviewMetricsFrom(data); if (!metrics.length) { @@ -4180,10 +4414,12 @@ def render_trace_detail_shell( setViewContent("overview", [grid]); } - function renderAgents(data, diagnostics) { + function renderAgents(data, diagnostics, bounds) { var agents = agentsFrom(data); + var notice = boundsNoticeNode(bounds, agents.length); + var controls = paginationToolbar(bounds); if (!agents.length) { - setViewContent("agents", [emptyStateNode("No data found for the selected filters.")]); + setViewContent("agents", [controls, notice, emptyStateNode("No data found for the selected filters.")]); return; } var rows = agents.map(function (agent) { @@ -4262,7 +4498,7 @@ def render_trace_detail_shell( "\u2014" ] ); - setViewContent("agents", [table]); + setViewContent("agents", [controls, notice, table]); } // --------------------------------------------------------------------- @@ -4535,10 +4771,12 @@ def render_trace_detail_shell( }); } - function renderUsage(data, diagnostics) { + function renderUsage(data, diagnostics, bounds) { var usage = modelsFrom(data); + var notice = boundsNoticeNode(bounds, usage.length); + var controls = paginationToolbar(bounds); if (!usage.length) { - setViewContent("usage", [emptyStateNode("No data found for the selected filters.")]); + setViewContent("usage", [controls, notice, emptyStateNode("No data found for the selected filters.")]); return; } var rows = usage.map(function (entry) { @@ -4612,14 +4850,16 @@ def render_trace_detail_shell( "\u2014" ] ); - setViewContent("usage", [table]); + setViewContent("usage", [controls, notice, table]); } function renderTools(data, diagnostics, bounds) { var tools = toolsFrom(data); var notice = boundsNoticeNode(bounds, tools.length); + var controls = paginationToolbar(bounds); if (!tools.length) { setViewContent("tools", [ + controls, notice, emptyStateNode("No tool activity was found for the selected filters. Tool attribution may not be reported for this selection."), ]); @@ -4675,14 +4915,16 @@ def render_trace_detail_shell( "\u2014" ] ); - setViewContent("tools", [notice, table]); + setViewContent("tools", [controls, notice, table]); } function renderRuns(data, diagnostics, bounds) { var runs = runsFrom(data); var notice = boundsNoticeNode(bounds, runs.length); + var controls = paginationToolbar(bounds); if (!runs.length) { setViewContent("runs", [ + controls, notice, emptyStateNode("No runs could be correlated for the selected filters. Run correlation may not be reported for this selection."), ]); @@ -4763,7 +5005,7 @@ def render_trace_detail_shell( renderMaybeMissing(sumReported(runs, "reasoning_tokens"), { missingText: "\u2014" }) ] ); - setViewContent("runs", [notice, table]); + setViewContent("runs", [controls, notice, table]); } function costLabel(value) { @@ -5657,9 +5899,9 @@ def render_trace_detail_shell( if (view === "overview") { renderOverview(body.data, body.diagnostics); } else if (view === "agents") { - renderAgents(body.data, body.diagnostics); + renderAgents(body.data, body.diagnostics, body.bounds); } else if (view === "usage") { - renderUsage(body.data, body.diagnostics); + renderUsage(body.data, body.diagnostics, body.bounds); } else if (view === "tools") { renderTools(body.data, body.diagnostics, body.bounds); } else if (view === "runs") { @@ -5774,6 +6016,11 @@ def render_trace_detail_shell( end: appliedFilters.end, }, refresh: manual === true, + page: currentPage, + page_size: currentPageSize, + search: currentSearch || null, + sort_by: currentSortBy || null, + sort_direction: currentSortDirection, }; payload = currentView === "cost" ? buildCostPayload(manual) : payload; if (currentView === "departments") { @@ -5888,6 +6135,7 @@ def render_trace_detail_shell( appliedFilters.attribution_cost_period_id = preservedAttributionFilters.attribution_cost_period_id; appliedFilters.attribution_cost_component_id = preservedAttributionFilters.attribution_cost_component_id; } + resetPaging(false); syncUrl(); fetchObserveData(true); }); @@ -5964,7 +6212,9 @@ def render_trace_detail_shell( document.querySelectorAll("[data-observe-nav-link]").forEach(function (link) { link.addEventListener("click", function (event) { event.preventDefault(); - activateView(link.getAttribute("data-observe-nav-link")); + var nextView = link.getAttribute("data-observe-nav-link"); + if (nextView !== currentView) resetPaging(true); + activateView(nextView); pushUrl(); // Switching views queries a different `ObserveQuery.view`, so the // newly active view must be fetched -- otherwise it would only ever diff --git a/src/agentops/core/observe.py b/src/agentops/core/observe.py index d4d08a3c..db8b65e0 100644 --- a/src/agentops/core/observe.py +++ b/src/agentops/core/observe.py @@ -47,8 +47,11 @@ "protected_or_unavailable", ] -# This mirrors the bounded Observe query contract without coupling core to agent code. -MAX_ROWS_PER_QUERY = 500 +# Aggregate queries can retain enough rows for large fleets while API responses +# remain independently page-bounded. +MAX_ROWS_PER_QUERY = 5000 +DEFAULT_PAGE_SIZE = 50 +MAX_PAGE_SIZE = 100 _SUBSCRIPTION_RE = re.compile(r"^/subscriptions/[^/]+$", re.IGNORECASE) _RESOURCE_GROUP_RE = re.compile( @@ -426,6 +429,23 @@ class ObserveQueryRequest(ContractModel): view: ObserveView filters: ObserveFilterState refresh: bool = False + page: int = Field(default=1, ge=1, le=1000) + page_size: int = Field(default=DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE) + search: str | None = Field(default=None, max_length=200) + sort_by: str | None = Field( + default=None, + max_length=64, + pattern=r"^[a-z][a-z0-9_]*$", + ) + sort_direction: Literal["asc", "desc"] = "desc" + + @field_validator("search") + @classmethod + def _normalize_search(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + return normalized or None class AgentDetailRequest(ContractModel): @@ -482,7 +502,10 @@ class QueryDiagnostics(ContractModel): started_at: datetime completed_at: datetime duration_ms: int = Field(ge=0) - source_count: int = Field(ge=0, le=10) + discovery_duration_ms: int = Field(default=0, ge=0) + query_duration_ms: int = Field(default=0, ge=0) + normalization_duration_ms: int = Field(default=0, ge=0) + source_count: int = Field(ge=0) successful_sources: int = Field(ge=0) partial_sources: int = Field(ge=0) failed_sources: int = Field(ge=0) @@ -629,6 +652,10 @@ class ResultBounds(ContractModel): rows_shown: int = Field(ge=0, le=MAX_ROWS_PER_QUERY) rows_total_in_scope: int | None = Field(default=None, ge=0) truncated: bool = False + page: int | None = Field(default=None, ge=1) + page_size: int | None = Field(default=None, ge=1, le=MAX_PAGE_SIZE) + has_previous_page: bool = False + has_next_page: bool = False @model_validator(mode="after") def _validate_bounds(self) -> "ResultBounds": @@ -637,10 +664,8 @@ def _validate_bounds(self) -> "ResultBounds": and self.rows_total_in_scope < self.rows_shown ): raise ValueError("rows_total_in_scope cannot be less than rows_shown") - if self.truncated and self.rows_shown != MAX_ROWS_PER_QUERY: - raise ValueError( - "truncated results must show exactly MAX_ROWS_PER_QUERY rows" - ) + if (self.page is None) != (self.page_size is None): + raise ValueError("page and page_size must be reported together") return self diff --git a/tests/unit/__snapshots__/observe_overview.html b/tests/unit/__snapshots__/observe_overview.html index 9bbe6ebe..26bc5159 100644 --- a/tests/unit/__snapshots__/observe_overview.html +++ b/tests/unit/__snapshots__/observe_overview.html @@ -310,12 +310,42 @@ background: color-mix(in srgb, var(--observe-accent) 26%, transparent); } .observe-refresh-button:hover { border-color: var(--observe-border-strong); } +.observe-refresh-button:disabled { + cursor: not-allowed; + opacity: 0.45; +} .observe-apply-button:focus-visible, .observe-refresh-button:focus-visible { outline: 2px solid var(--observe-accent); outline-offset: 2px; } .observe-refresh-status { color: var(--observe-muted); font-size: 12px; } +.observe-page-controls { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 8px; +} +.observe-page-controls input, +.observe-page-controls select { + box-sizing: border-box; + min-height: 32px; + border: 1px solid var(--observe-border); + border-radius: 8px; + background: var(--observe-card-bg); + color: var(--observe-fg); + font: inherit; + font-size: 13px; + padding: 5px 9px; +} +.observe-page-controls input { min-width: 220px; } +.observe-page-status { + color: var(--observe-muted); + font-size: 12px; + min-width: 48px; + text-align: center; +} .observe-scope { margin: 0 0 10px; font-size: 12px; color: var(--observe-muted); } .observe-attribution-summary { @@ -534,12 +564,43 @@ .observe-badge { display: inline-flex; align-items: center; + justify-content: center; + min-height: 24px; + box-sizing: border-box; border-radius: 999px; - padding: 1px 8px; - font-size: 11px; - font-weight: 600; - line-height: 1.6; - border: 1px solid currentColor; + padding: 3px 9px; + font-size: 12px; + font-weight: 700; + line-height: 1.25; + letter-spacing: 0.01em; + white-space: nowrap; + border: 1px solid var(--observe-border-strong); + background: var(--observe-card-hi); +} +.observe-badge.observe-tone-ok { + border-color: color-mix(in srgb, var(--observe-ok) 52%, var(--observe-border)); + background: color-mix(in srgb, var(--observe-ok) 16%, var(--observe-card-bg)); + color: var(--observe-ok); +} +.observe-badge.observe-tone-warn { + border-color: color-mix(in srgb, var(--observe-warn) 52%, var(--observe-border)); + background: color-mix(in srgb, var(--observe-warn) 14%, var(--observe-card-bg)); + color: var(--observe-warn); +} +.observe-badge.observe-tone-crit { + border-color: color-mix(in srgb, var(--observe-crit) 52%, var(--observe-border)); + background: color-mix(in srgb, var(--observe-crit) 14%, var(--observe-card-bg)); + color: var(--observe-crit); +} +.observe-badge.observe-tone-info { + border-color: color-mix(in srgb, var(--observe-accent) 52%, var(--observe-border)); + background: color-mix(in srgb, var(--observe-accent) 14%, var(--observe-card-bg)); + color: var(--observe-accent); +} +.observe-badge.observe-tone-muted { + border-color: var(--observe-border-strong); + background: color-mix(in srgb, var(--observe-muted) 10%, var(--observe-card-bg)); + color: var(--observe-muted); } .observe-tone-ok { color: var(--observe-ok); } .observe-tone-warn { color: var(--observe-warn); } @@ -777,6 +838,7 @@

Runs

var ATTRIBUTION_COST_UNAVAILABLE = "Cost attribution is unavailable. Configure a valid cost model and allocatable cost before selecting Cost."; var AUTO_REFRESH_MS = 300000; // five minutes var DEFAULT_RANGE_MS = 24 * 60 * 60 * 1000; // trailing 24 hours + var CACHE_WINDOW_MS = 60 * 1000; // align default windows across browser sessions // Mirrors MAX_TREND_POINTS in ui.py: even though the backend is expected // to already bound each trend series (T053), the client re-bounds // defensively so a chart never renders unbounded markup regardless of @@ -786,6 +848,39 @@

Runs

// from contracts/observe-api.openapi.yaml (mirrors OBSERVE_VIEW_WIRE_NAMES // in ui.py -- the internal "usage" id is spelled "models" on the wire). var VIEW_WIRE_NAMES = { overview: "overview", agents: "agents", usage: "models", tools: "tools", runs: "runs" }; + var SERVER_SORT_KEYS = { + "observe-agents-table": { + "Agent": "agent_name", "Source": "source_kind", "Model": "model", + "Last seen": "last_seen", "Invocations": "invocations", + "Failure rate": "failure_rate", "p95 latency": "p95_latency_ms", + "Input tokens": "input_tokens", "Output tokens": "output_tokens", + "Total tokens": "total_tokens", "Cache read": "cache_read_tokens", + "Cache write": "cache_write_tokens", "Reasoning": "reasoning_tokens" + }, + "observe-usage-table": { + "Model": "model", "Deployment": "deployment", "Requests": "requests", + "Failure rate": "failure_rate", "p95 latency": "p95_latency_ms", + "Input tokens": "input_tokens", "Output tokens": "output_tokens", + "Total tokens": "total_tokens", "Cache read": "cache_read_tokens", + "Cache write": "cache_write_tokens", "Reasoning": "reasoning_tokens", + "Last seen": "last_seen" + }, + "observe-tools-table": { + "Tool": "tool_name", "Agent": "agent_name", "Source": "source_id", + "Runtime": "source_kind", "Last seen": "last_seen", + "Invocations": "invocations", "Failures": "failures", + "p95 latency": "p95_latency_ms" + }, + "observe-runs-table": { + "Run key": "run_key", "Correlation": "run_key_kind", "Agent": "agent_name", + "Source": "source_id", "Runtime": "source_kind", "Started in range": "started_at", + "Duration in range": "duration_ms", "Status": "status", "Turns in range": "turns", + "Tool invocations": "tool_invocations", "Input tokens": "input_tokens", + "Output tokens": "output_tokens", "Total tokens": "total_tokens", + "Cache read": "cache_read_tokens", + "Cache write": "cache_write_tokens", "Reasoning": "reasoning_tokens" + } + }; // Best-effort, human-friendly labels for *documented* portal link keys // (mirrors _KNOWN_PORTAL_LABELS in ui.py). Any key not listed here still // renders (title-cased) rather than being dropped -- the "best-effort @@ -804,6 +899,11 @@

Runs

var draftFilters = {}; var appliedFilters = {}; var currentView = "overview"; + var currentPage = 1; + var currentPageSize = 50; + var currentSearch = ""; + var currentSortBy = ""; + var currentSortDirection = "desc"; var requestToken = 0; var activeController = null; var refreshTimer = null; @@ -846,11 +946,22 @@

Runs

if (currentView === "cost" && !document.getElementById("cost")) { currentView = "overview"; } + currentPage = Math.max(1, parseInt(params.get("page") || "1", 10) || 1); + currentPageSize = [25, 50, 100].indexOf(parseInt(params.get("page_size") || "50", 10)) >= 0 + ? parseInt(params.get("page_size") || "50", 10) + : 50; + currentSearch = String(params.get("search") || "").slice(0, 200); + currentSortBy = String(params.get("sort_by") || "").slice(0, 64); + currentSortDirection = params.get("sort_direction") === "asc" ? "asc" : "desc"; if (!applied.start || !applied.end) { - var end = new Date(); - var start = new Date(end.getTime() - DEFAULT_RANGE_MS); - applied.start = applied.start || start.toISOString(); - applied.end = applied.end || end.toISOString(); + var end = applied.end + ? new Date(applied.end) + : new Date(Math.floor(Date.now() / CACHE_WINDOW_MS) * CACHE_WINDOW_MS); + var start = applied.start + ? new Date(applied.start) + : new Date(end.getTime() - DEFAULT_RANGE_MS); + applied.start = start.toISOString(); + applied.end = end.toISOString(); } return applied; } @@ -877,6 +988,13 @@

Runs

}); } params.set("view", currentView); + if (isPagedView(currentView)) { + params.set("page", String(currentPage)); + params.set("page_size", String(currentPageSize)); + if (currentSearch) params.set("search", currentSearch); + if (currentSortBy) params.set("sort_by", currentSortBy); + params.set("sort_direction", currentSortDirection); + } params.set("theme", document.documentElement.getAttribute("data-theme") || "dark"); return window.location.pathname + "?" + params.toString(); } @@ -1359,13 +1477,27 @@

Runs

var label = header.dataset.label || String(header.textContent || "").trim() || "Column " + (columnIndex + 1); var helpText = header.dataset.help || ""; + var serverSortKey = header.dataset.serverSortKey || ""; clearChildren(header); - header.setAttribute("aria-sort", "none"); + var activeDirection = serverSortKey && currentSortBy === serverSortKey + ? (currentSortDirection === "asc" ? "ascending" : "descending") + : "none"; + header.setAttribute("aria-sort", activeDirection); var button = makeEl("button", "observe-sort-button", label); button.type = "button"; button.title = "Sort by " + label; button.setAttribute("aria-label", "Sort by " + label); button.addEventListener("click", function () { + if (serverSortKey) { + currentSortDirection = currentSortBy === serverSortKey && currentSortDirection === "asc" + ? "desc" + : "asc"; + currentSortBy = serverSortKey; + currentPage = 1; + syncUrl(); + fetchObserveData(false); + return; + } body.querySelectorAll("[data-observe-drilldown-row]").forEach(function (detailRow) { detailRow.remove(); }); @@ -1436,6 +1568,7 @@

Runs

table.setAttribute("aria-label", ariaLabel); var thead = document.createElement("thead"); var headRow = document.createElement("tr"); + var sortKeys = SERVER_SORT_KEYS[className] || {}; columns.forEach(function (column) { var definition = typeof column === "string" ? { label: column } : column; var th = makeEl("th", null, definition.label); @@ -1444,6 +1577,9 @@

Runs

if (definition.help) { th.dataset.help = definition.help; } + if (sortKeys[definition.label]) { + th.dataset.serverSortKey = sortKeys[definition.label]; + } headRow.appendChild(th); }); thead.appendChild(headRow); @@ -1689,12 +1825,110 @@

Runs

function boundsNoticeNode(bounds, rowsShown) { var total = bounds && bounds.rows_total_in_scope; - var text = total === null || total === undefined - ? "Showing " + rowsShown + " rows." - : "Showing " + rowsShown + " of " + total + " rows in scope."; + var page = bounds && bounds.page ? bounds.page : currentPage; + var pageSize = bounds && bounds.page_size ? bounds.page_size : currentPageSize; + var first = rowsShown ? ((page - 1) * pageSize) + 1 : 0; + var last = rowsShown ? first + rowsShown - 1 : 0; + var text; + if (!rowsShown && page > 1) { + text = "No rows are available on page " + page + "."; + } else if (bounds && bounds.truncated) { + text = "Showing rows " + first + "–" + last + " from the highest-ranked results"; + text += total === null || total === undefined + ? "." + : " (" + total + " rows in scope)."; + } else { + text = total === null || total === undefined + ? "Showing rows " + first + "–" + last + "." + : "Showing rows " + first + "–" + last + " of " + total + "."; + } return makeEl("p", "observe-hint observe-bounds-notice", text); } + function isPagedView(view) { + return ["agents", "usage", "tools", "runs"].indexOf(view) >= 0; + } + + function resetPaging(resetQuery) { + currentPage = 1; + if (resetQuery) { + currentSearch = ""; + currentSortBy = ""; + currentSortDirection = "desc"; + } + } + + function paginationToolbar(bounds) { + var toolbar = makeEl("form", "observe-page-controls"); + toolbar.setAttribute("role", "search"); + toolbar.addEventListener("submit", function (event) { + event.preventDefault(); + currentSearch = String(search.value || "").trim().slice(0, 200); + currentPageSize = parseInt(pageSize.value, 10) || 50; + currentPage = 1; + syncUrl(); + fetchObserveData(false); + }); + + var search = document.createElement("input"); + search.type = "search"; + search.value = currentSearch; + search.placeholder = "Search this view"; + search.setAttribute("aria-label", "Search this view"); + toolbar.appendChild(search); + + var pageSize = document.createElement("select"); + pageSize.setAttribute("aria-label", "Rows per page"); + [25, 50, 100].forEach(function (size) { + var option = document.createElement("option"); + option.value = String(size); + option.textContent = size + " rows"; + option.selected = size === currentPageSize; + pageSize.appendChild(option); + }); + toolbar.appendChild(pageSize); + toolbar.appendChild(makeEl("button", "observe-refresh-button", "Search")); + var clear = makeEl("button", "observe-refresh-button", "Clear"); + clear.type = "button"; + clear.disabled = !currentSearch; + clear.addEventListener("click", function () { + currentSearch = ""; + currentPage = 1; + syncUrl(); + fetchObserveData(false); + }); + toolbar.appendChild(clear); + + var previous = makeEl("button", "observe-refresh-button", "Previous"); + previous.type = "button"; + previous.disabled = !(bounds && bounds.has_previous_page); + previous.addEventListener("click", function () { + currentPage = Math.max(1, currentPage - 1); + syncUrl(); + fetchObserveData(false); + }); + toolbar.appendChild(previous); + + var status = makeEl( + "span", + "observe-page-status", + "Page " + String((bounds && bounds.page) || currentPage) + ); + status.setAttribute("aria-live", "polite"); + toolbar.appendChild(status); + + var next = makeEl("button", "observe-refresh-button", "Next"); + next.type = "button"; + next.disabled = !(bounds && bounds.has_next_page); + next.addEventListener("click", function () { + currentPage += 1; + syncUrl(); + fetchObserveData(false); + }); + toolbar.appendChild(next); + return toolbar; + } + function renderOverview(data, diagnostics) { var metrics = overviewMetricsFrom(data); if (!metrics.length) { @@ -1727,10 +1961,12 @@

Runs

setViewContent("overview", [grid]); } - function renderAgents(data, diagnostics) { + function renderAgents(data, diagnostics, bounds) { var agents = agentsFrom(data); + var notice = boundsNoticeNode(bounds, agents.length); + var controls = paginationToolbar(bounds); if (!agents.length) { - setViewContent("agents", [emptyStateNode("No data found for the selected filters.")]); + setViewContent("agents", [controls, notice, emptyStateNode("No data found for the selected filters.")]); return; } var rows = agents.map(function (agent) { @@ -1809,7 +2045,7 @@

Runs

"—" ] ); - setViewContent("agents", [table]); + setViewContent("agents", [controls, notice, table]); } // --------------------------------------------------------------------- @@ -2082,10 +2318,12 @@

Runs

}); } - function renderUsage(data, diagnostics) { + function renderUsage(data, diagnostics, bounds) { var usage = modelsFrom(data); + var notice = boundsNoticeNode(bounds, usage.length); + var controls = paginationToolbar(bounds); if (!usage.length) { - setViewContent("usage", [emptyStateNode("No data found for the selected filters.")]); + setViewContent("usage", [controls, notice, emptyStateNode("No data found for the selected filters.")]); return; } var rows = usage.map(function (entry) { @@ -2159,14 +2397,16 @@

Runs

"—" ] ); - setViewContent("usage", [table]); + setViewContent("usage", [controls, notice, table]); } function renderTools(data, diagnostics, bounds) { var tools = toolsFrom(data); var notice = boundsNoticeNode(bounds, tools.length); + var controls = paginationToolbar(bounds); if (!tools.length) { setViewContent("tools", [ + controls, notice, emptyStateNode("No tool activity was found for the selected filters. Tool attribution may not be reported for this selection."), ]); @@ -2222,14 +2462,16 @@

Runs

"—" ] ); - setViewContent("tools", [notice, table]); + setViewContent("tools", [controls, notice, table]); } function renderRuns(data, diagnostics, bounds) { var runs = runsFrom(data); var notice = boundsNoticeNode(bounds, runs.length); + var controls = paginationToolbar(bounds); if (!runs.length) { setViewContent("runs", [ + controls, notice, emptyStateNode("No runs could be correlated for the selected filters. Run correlation may not be reported for this selection."), ]); @@ -2310,7 +2552,7 @@

Runs

renderMaybeMissing(sumReported(runs, "reasoning_tokens"), { missingText: "—" }) ] ); - setViewContent("runs", [notice, table]); + setViewContent("runs", [controls, notice, table]); } function costLabel(value) { @@ -3204,9 +3446,9 @@

Runs

if (view === "overview") { renderOverview(body.data, body.diagnostics); } else if (view === "agents") { - renderAgents(body.data, body.diagnostics); + renderAgents(body.data, body.diagnostics, body.bounds); } else if (view === "usage") { - renderUsage(body.data, body.diagnostics); + renderUsage(body.data, body.diagnostics, body.bounds); } else if (view === "tools") { renderTools(body.data, body.diagnostics, body.bounds); } else if (view === "runs") { @@ -3321,6 +3563,11 @@

Runs

end: appliedFilters.end, }, refresh: manual === true, + page: currentPage, + page_size: currentPageSize, + search: currentSearch || null, + sort_by: currentSortBy || null, + sort_direction: currentSortDirection, }; payload = currentView === "cost" ? buildCostPayload(manual) : payload; if (currentView === "departments") { @@ -3435,6 +3682,7 @@

Runs

appliedFilters.attribution_cost_period_id = preservedAttributionFilters.attribution_cost_period_id; appliedFilters.attribution_cost_component_id = preservedAttributionFilters.attribution_cost_component_id; } + resetPaging(false); syncUrl(); fetchObserveData(true); }); @@ -3511,7 +3759,9 @@

Runs

document.querySelectorAll("[data-observe-nav-link]").forEach(function (link) { link.addEventListener("click", function (event) { event.preventDefault(); - activateView(link.getAttribute("data-observe-nav-link")); + var nextView = link.getAttribute("data-observe-nav-link"); + if (nextView !== currentView) resetPaging(true); + activateView(nextView); pushUrl(); // Switching views queries a different `ObserveQuery.view`, so the // newly active view must be fetched -- otherwise it would only ever diff --git a/tests/unit/__snapshots__/observe_styles.css b/tests/unit/__snapshots__/observe_styles.css index ddaf748b..bf8f4a9c 100644 --- a/tests/unit/__snapshots__/observe_styles.css +++ b/tests/unit/__snapshots__/observe_styles.css @@ -304,12 +304,42 @@ background: color-mix(in srgb, var(--observe-accent) 26%, transparent); } .observe-refresh-button:hover { border-color: var(--observe-border-strong); } +.observe-refresh-button:disabled { + cursor: not-allowed; + opacity: 0.45; +} .observe-apply-button:focus-visible, .observe-refresh-button:focus-visible { outline: 2px solid var(--observe-accent); outline-offset: 2px; } .observe-refresh-status { color: var(--observe-muted); font-size: 12px; } +.observe-page-controls { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 8px; +} +.observe-page-controls input, +.observe-page-controls select { + box-sizing: border-box; + min-height: 32px; + border: 1px solid var(--observe-border); + border-radius: 8px; + background: var(--observe-card-bg); + color: var(--observe-fg); + font: inherit; + font-size: 13px; + padding: 5px 9px; +} +.observe-page-controls input { min-width: 220px; } +.observe-page-status { + color: var(--observe-muted); + font-size: 12px; + min-width: 48px; + text-align: center; +} .observe-scope { margin: 0 0 10px; font-size: 12px; color: var(--observe-muted); } .observe-attribution-summary { @@ -528,12 +558,43 @@ tfoot td { .observe-badge { display: inline-flex; align-items: center; + justify-content: center; + min-height: 24px; + box-sizing: border-box; border-radius: 999px; - padding: 1px 8px; - font-size: 11px; - font-weight: 600; - line-height: 1.6; - border: 1px solid currentColor; + padding: 3px 9px; + font-size: 12px; + font-weight: 700; + line-height: 1.25; + letter-spacing: 0.01em; + white-space: nowrap; + border: 1px solid var(--observe-border-strong); + background: var(--observe-card-hi); +} +.observe-badge.observe-tone-ok { + border-color: color-mix(in srgb, var(--observe-ok) 52%, var(--observe-border)); + background: color-mix(in srgb, var(--observe-ok) 16%, var(--observe-card-bg)); + color: var(--observe-ok); +} +.observe-badge.observe-tone-warn { + border-color: color-mix(in srgb, var(--observe-warn) 52%, var(--observe-border)); + background: color-mix(in srgb, var(--observe-warn) 14%, var(--observe-card-bg)); + color: var(--observe-warn); +} +.observe-badge.observe-tone-crit { + border-color: color-mix(in srgb, var(--observe-crit) 52%, var(--observe-border)); + background: color-mix(in srgb, var(--observe-crit) 14%, var(--observe-card-bg)); + color: var(--observe-crit); +} +.observe-badge.observe-tone-info { + border-color: color-mix(in srgb, var(--observe-accent) 52%, var(--observe-border)); + background: color-mix(in srgb, var(--observe-accent) 14%, var(--observe-card-bg)); + color: var(--observe-accent); +} +.observe-badge.observe-tone-muted { + border-color: var(--observe-border-strong); + background: color-mix(in srgb, var(--observe-muted) 10%, var(--observe-card-bg)); + color: var(--observe-muted); } .observe-tone-ok { color: var(--observe-ok); } .observe-tone-warn { color: var(--observe-warn); } diff --git a/tests/unit/test_cockpit.py b/tests/unit/test_cockpit.py index 7638ee96..747a5fc1 100644 --- a/tests/unit/test_cockpit.py +++ b/tests/unit/test_cockpit.py @@ -1675,6 +1675,11 @@ async def query( filters, refresh=False, user_context=None, + page=1, + page_size=50, + search=None, + sort_by=None, + sort_direction="desc", ): return { "view": view, @@ -1710,6 +1715,71 @@ async def attribution(self, *, request, user_context=None): } +def test_observe_query_forwards_paging_and_reports_server_timing(tmp_path: Path) -> None: + from fastapi.testclient import TestClient + + from agentops.agent.cockpit import create_app + + class TimingObserveService: + def __init__(self) -> None: + self.request: dict = {} + + async def query(self, **kwargs): + self.request = kwargs + return { + "view": kwargs["view"], + "data": [], + "cache_status": "miss", + "diagnostics": { + "discovery_duration_ms": 12.5, + "query_duration_ms": 34.5, + "normalization_duration_ms": 2.0, + }, + } + + service = TimingObserveService() + client = TestClient( + create_app( + tmp_path, + mode="local", + observe_scope={ + "version": 1, + "mode": "projects", + "project_resource_ids": [ + "/subscriptions/sub/resourceGroups/rg/providers/" + "Microsoft.CognitiveServices/accounts/a/projects/p" + ], + }, + observe_service=service, + ) + ) + + response = client.post( + "/api/observe/query", + json={ + "view": "agents", + "filters": _OBSERVE_FILTERS, + "page": 3, + "page_size": 25, + "search": "planner", + "sort_by": "invocations", + "sort_direction": "asc", + }, + ) + + assert response.status_code == 200 + assert service.request["page"] == 3 + assert service.request["page_size"] == 25 + assert service.request["search"] == "planner" + assert service.request["sort_by"] == "invocations" + assert service.request["sort_direction"] == "asc" + timing = response.headers["server-timing"] + assert "total;dur=" in timing + assert "discovery;dur=12.5" in timing + assert "monitor;dur=34.5" in timing + assert "normalize;dur=2.0" in timing + + _OBSERVE_FILTERS = { "start": "2026-08-01T00:00:00Z", "end": "2026-09-01T00:00:00Z", diff --git a/tests/unit/test_observe_adapters.py b/tests/unit/test_observe_adapters.py index af95b2bf..11fa6c5d 100644 --- a/tests/unit/test_observe_adapters.py +++ b/tests/unit/test_observe_adapters.py @@ -11,6 +11,7 @@ from __future__ import annotations +import asyncio import builtins import sys from datetime import datetime, timedelta, timezone @@ -761,6 +762,50 @@ async def test_azure_query_client_chunks_more_than_ten_sources() -> None: assert all(result.status == "success" for result in results) +@pytest.mark.asyncio +async def test_azure_query_client_limits_chunk_concurrency_and_preserves_order() -> None: + class DelayedLogsClient(_FakeLogsClient): + def __init__(self) -> None: + super().__init__() + self.active = 0 + self.max_active = 0 + + async def query_batch(self, requests): + batch_index = len(self.batches) + self.batches.append(list(requests)) + self.active += 1 + self.max_active = max(self.max_active, self.active) + await asyncio.sleep(0.01 * (3 - batch_index)) + self.active -= 1 + return [ + SimpleNamespace( + error=None, + partial_error=None, + tables=[], + status="SUCCESS", + ) + for _ in requests + ] + + client = AzureQueryClient( + credential="fake-credential", + max_concurrent_batches=2, + ) + logs_client = DelayedLogsClient() + client._logs_client = logs_client + sources = [ + _make_source(f"source-{index}") + for index in range((MAX_SOURCES_PER_BATCH * 3) - 1) + ] + + results = await client.query(sources, _make_filters(), view="overview") + + assert logs_client.max_active == 2 + assert [result.source_id for result in results] == [ + source.source_id for source in sources + ] + + @pytest.mark.asyncio async def test_azure_query_client_skips_sources_without_workspace_id() -> None: client = AzureQueryClient(credential="fake-credential") diff --git a/tests/unit/test_observe_cache.py b/tests/unit/test_observe_cache.py index f75e1810..1698d17d 100644 --- a/tests/unit/test_observe_cache.py +++ b/tests/unit/test_observe_cache.py @@ -36,6 +36,20 @@ def test_ttl_identity_keying_refresh_and_eviction() -> None: assert cache.get("c") == 3 +def test_lookup_distinguishes_fresh_stale_and_miss() -> None: + clock = FakeClock() + cache = ObserveCache(ttl_seconds=10, clock=clock) + cache.set("key", {"value": 1}) + + assert cache.lookup("key").state == "fresh" + clock.now += 11 + stale = cache.lookup("key", max_stale_seconds=5) + assert stale.state == "stale" + assert stale.value == {"value": 1} + clock.now += 5 + assert cache.lookup("key", max_stale_seconds=5).state == "miss" + + @pytest.mark.parametrize( "value", [ diff --git a/tests/unit/test_observe_facade.py b/tests/unit/test_observe_facade.py index 3edb6396..76b19427 100644 --- a/tests/unit/test_observe_facade.py +++ b/tests/unit/test_observe_facade.py @@ -828,6 +828,10 @@ async def test_query_tools_view_normalizes_rows_and_serializes_bounds() -> None: "rows_shown": 1, "rows_total_in_scope": 1, "truncated": False, + "page": 1, + "page_size": 50, + "has_previous_page": False, + "has_next_page": False, } @@ -1287,6 +1291,10 @@ async def test_query_cost_preserves_explained_partial_and_missing_amount_states( "rows_shown": 2, "rows_total_in_scope": 3, "truncated": False, + "page": None, + "page_size": None, + "has_previous_page": False, + "has_next_page": False, } assert result["coverage"][0]["state"] == "partial" assert result["coverage"][0]["component_id"] == "gpt-ptu-prod" @@ -2049,11 +2057,9 @@ def set(self, key, value): # type: ignore[override] assert result["input_messages"] == ["hello"] assert FakeLogsQueryAdapter.instances, "expected the fake logs adapter to be used" assert FakeLogsQueryAdapter.instances[0].closed is True - # get_inventory (non-sensitive) may cache; the protected content itself - # must never be written through ObserveCache.set. - for call_args in cache.set_calls, cache._entries: # pragma: no cover - sanity only - pass - assert cache.set_calls == 1 # only the inventory cache write from get_inventory + # Inventory uses a dedicated cache; protected content never writes to the + # shared aggregate cache. + assert cache.set_calls == 0 @pytest.mark.asyncio diff --git a/tests/unit/test_observe_models.py b/tests/unit/test_observe_models.py index ac537b73..5c12b59c 100644 --- a/tests/unit/test_observe_models.py +++ b/tests/unit/test_observe_models.py @@ -251,16 +251,16 @@ def test_attribution_filter_tokens_are_nullable_trimmed_and_url_safe() -> None: def test_result_bounds_enforce_total_and_truncation_invariants() -> None: assert ResultBounds(rows_shown=0).rows_total_in_scope is None - assert ResultBounds(rows_shown=500, rows_total_in_scope=501, truncated=True) + assert ResultBounds(rows_shown=5000, rows_total_in_scope=5001, truncated=True) with pytest.raises(ValidationError, match="less than"): ResultBounds(rows_shown=2, rows_total_in_scope=1) - with pytest.raises(ValidationError, match="MAX_ROWS_PER_QUERY"): - ResultBounds(rows_shown=499, truncated=True) + with pytest.raises(ValidationError, match="page and page_size"): + ResultBounds(rows_shown=50, page=1) with pytest.raises(ValidationError): ResultBounds(rows_shown=1, unexpected=True) with pytest.raises(ValidationError): - ResultBounds(rows_shown=501) + ResultBounds(rows_shown=5001) def test_observe_api_requests_are_strict_and_canonical() -> None: @@ -287,6 +287,9 @@ def test_observe_api_requests_are_strict_and_canonical() -> None: ) assert query.refresh is False + assert query.page == 1 + assert query.page_size == 50 + assert query.sort_direction == "desc" assert "cost" in get_args(ObserveView) assert ObserveQueryRequest(view="cost", filters=filters).view == "cost" assert ObserveQueryRequest(view="tools", filters=filters).view == "tools" @@ -297,6 +300,8 @@ def test_observe_api_requests_are_strict_and_canonical() -> None: assert content.source_resource_id == PROJECT.lower() with pytest.raises(ValidationError): ObserveQueryRequest(view="unknown", filters=filters) + with pytest.raises(ValidationError): + ObserveQueryRequest(view="agents", filters=filters, page_size=101) with pytest.raises(ValidationError): ObserveDrilldownRequest( view="tools", diff --git a/tests/unit/test_observe_service.py b/tests/unit/test_observe_service.py index c22662f6..5ebffc5f 100644 --- a/tests/unit/test_observe_service.py +++ b/tests/unit/test_observe_service.py @@ -10,6 +10,7 @@ from __future__ import annotations +import asyncio from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from decimal import Decimal @@ -17,6 +18,7 @@ import pytest +import agentops.agent.observe.service as observe_service_module from agentops.agent.observe.cache import ObserveCache, SensitiveValueError from agentops.agent.observe.queries import MAX_ROWS_PER_QUERY, SourceResult from agentops.agent.observe.service import ( @@ -53,11 +55,18 @@ derive_pseudonymous_user_key, issue_user_filter_token, ) -from agentops.core.cost import CostComponent, CostModel, CostPeriod, CostUsageObservation +from agentops.core.cost import ( + MAX_COST_ROWS, + CostComponent, + CostModel, + CostPeriod, + CostUsageObservation, +) from agentops.core.observe import ( AttributionQueryRequest, ModelUsage, ObserveFilterState, + ObserveQueryRequest, ObserveScope, ResourceInventory, TelemetrySource, @@ -802,7 +811,8 @@ def test_result_bounds_combines_truncated_multi_source_totals() -> None: first = SourceResult( source_id="src-a", status="success", - tables=[{"total_in_scope": 600}] * MAX_ROWS_PER_QUERY, + tables=[{"total_in_scope": MAX_ROWS_PER_QUERY + 100}] + * MAX_ROWS_PER_QUERY, ) second = SourceResult( source_id="src-b", @@ -812,7 +822,7 @@ def test_result_bounds_combines_truncated_multi_source_totals() -> None: bounds = _result_bounds([first, second], rows_shown=MAX_ROWS_PER_QUERY) - assert bounds.rows_total_in_scope == 610 + assert bounds.rows_total_in_scope == MAX_ROWS_PER_QUERY + 110 assert bounds.rows_shown == MAX_ROWS_PER_QUERY assert bounds.truncated is True @@ -1554,12 +1564,12 @@ async def test_query_cost_keeps_omitted_amounts_when_agent_rows_are_bounded() -> assert all(summary.rows_total == 501 for summary in result.data.components) assert ( sum(summary.rows_shown for summary in result.data.components) - == MAX_ROWS_PER_QUERY + == MAX_COST_ROWS ) assert all( summary.omitted_allocated_amount > 0 for summary in result.data.components ) - assert len(result.data.rows) == MAX_ROWS_PER_QUERY + assert len(result.data.rows) == MAX_COST_ROWS @pytest.mark.asyncio @@ -2404,6 +2414,178 @@ async def test_query_view_caches_for_two_minutes_and_serves_hits_without_requery assert second.refreshed_at == first.refreshed_at +@pytest.mark.asyncio +async def test_query_view_pages_searches_and_sorts_one_cached_aggregate() -> None: + clock = FakeDatetimeClock(datetime(2024, 1, 1, tzinfo=timezone.utc)) + inventory = _inventory([_source()]) + rows = [ + { + "agent_key": f"agent-{index:03d}", + "agent_id": f"agent-{index:03d}", + "agent_name": f"Agent {index:03d}", + "model": "gpt-4o", + "invocations": index, + "failures": 0, + "last_seen": datetime(2024, 1, 1, 8, tzinfo=timezone.utc), + "total_in_scope": 120, + } + for index in range(120) + ] + service, _discovery, query = _service( + inventory=inventory, + results=[SourceResult(source_id="src-1", status="success", tables=rows)], + clock=clock, + ) + + second_page = await service.query_view( + _scope(), + _filters(), + view="agents", + page=2, + page_size=50, + ) + filtered = await service.query_view( + _scope(), + _filters(), + view="agents", + page_size=25, + search="Agent 11", + sort_by="agent_name", + sort_direction="asc", + ) + + assert len(query.calls) == 1 + assert [row.agent_id for row in second_page.data[:2]] == [ + "agent-069", + "agent-068", + ] + assert second_page.bounds is not None + assert second_page.bounds.model_dump() == { + "rows_shown": 50, + "rows_total_in_scope": 120, + "truncated": False, + "page": 2, + "page_size": 50, + "has_previous_page": True, + "has_next_page": True, + } + assert [row.agent_id for row in filtered.data] == [ + f"agent-{index:03d}" for index in range(110, 120) + ] + assert filtered.bounds is not None + assert filtered.bounds.rows_total_in_scope == 10 + + +def test_every_allowlisted_view_sort_field_satisfies_request_contract() -> None: + for view, fields in observe_service_module._VIEW_SORT_FIELDS.items(): + for sort_field in fields: + request = ObserveQueryRequest( + view=view, + filters=_filters(), + sort_by=sort_field, + ) + assert request.sort_by == sort_field + + +@pytest.mark.asyncio +async def test_truncated_paging_stops_at_last_materialized_row( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(observe_service_module, "MAX_ROWS_PER_QUERY", 3) + rows = [ + { + "agent_key": f"agent-{index}", + "agent_id": f"agent-{index}", + "agent_name": f"Agent {index}", + "model": "gpt-4o", + "invocations": index, + "failures": 0, + "last_seen": datetime(2024, 1, 1, 8, tzinfo=timezone.utc), + "total_in_scope": 5, + } + for index in range(5) + ] + service, _discovery, query = _service( + inventory=_inventory([_source()]), + results=[SourceResult(source_id="src-1", status="success", tables=rows)], + clock=FakeDatetimeClock(datetime(2024, 1, 1, tzinfo=timezone.utc)), + ) + + first_page = await service.query_view( + _scope(), + _filters(), + view="agents", + page=1, + page_size=2, + ) + last_page = await service.query_view( + _scope(), + _filters(), + view="agents", + page=2, + page_size=2, + ) + filtered_last_page = await service.query_view( + _scope(), + _filters(), + view="agents", + page=2, + page_size=2, + search="Agent", + ) + + assert len(query.calls) == 1 + assert first_page.bounds is not None + assert first_page.bounds.has_next_page is True + assert last_page.bounds is not None + assert last_page.bounds.rows_shown == 1 + assert last_page.bounds.rows_total_in_scope == 5 + assert last_page.bounds.truncated is True + assert last_page.bounds.has_next_page is False + assert filtered_last_page.bounds is not None + assert filtered_last_page.bounds.rows_total_in_scope is None + assert filtered_last_page.bounds.has_next_page is False + + +@pytest.mark.asyncio +async def test_query_view_coalesces_concurrent_identical_misses() -> None: + entered = asyncio.Event() + release = asyncio.Event() + + class BlockingQueryClient(FakeQueryClient): + async def query(self, sources, filters, *, view): + self.calls.append((tuple(source.source_id for source in sources), view)) + entered.set() + await release.wait() + return self.results + + inventory = _inventory([_source()]) + discovery = FakeDiscoveryClient(inventory) + query = BlockingQueryClient([_agent_rows_result()]) + service = ObserveService( + discovery_client=discovery, + query_client=query, + runtime=FakeRuntime(), + clock=FakeDatetimeClock(datetime(2024, 1, 1, tzinfo=timezone.utc)), + cache=ObserveCache(ttl_seconds=CACHE_TTL_SECONDS), + ) + + first_task = asyncio.create_task( + service.query_view(_scope(), _filters(), view="agents") + ) + await entered.wait() + second_task = asyncio.create_task( + service.query_view(_scope(), _filters(), view="agents") + ) + await asyncio.sleep(0) + assert len(query.calls) == 1 + release.set() + first, second = await asyncio.gather(first_task, second_task) + + assert {first.cache_status, second.cache_status} == {"miss", "hit"} + assert first.data == second.data + + @pytest.mark.asyncio async def test_query_view_refresh_bypasses_cache_and_requeries() -> None: clock = FakeDatetimeClock( @@ -2425,7 +2607,7 @@ async def test_query_view_refresh_bypasses_cache_and_requeries() -> None: @pytest.mark.asyncio -async def test_query_view_expires_after_ttl() -> None: +async def test_query_view_serves_stale_after_ttl_and_refreshes_in_background() -> None: clock = FakeDatetimeClock(datetime(2024, 1, 1, tzinfo=timezone.utc)) cache = ObserveCache(ttl_seconds=CACHE_TTL_SECONDS, clock=lambda: cache_clock.now) cache_clock = FakeDatetimeClock( @@ -2449,8 +2631,10 @@ def __call__(self) -> float: await service.query_view(_scope(), _filters(), view="agents") float_clock.value += CACHE_TTL_SECONDS + 1 - await service.query_view(_scope(), _filters(), view="agents") + stale = await service.query_view(_scope(), _filters(), view="agents") + await asyncio.gather(*service._background_tasks) + assert stale.cache_status == "stale" assert len(query.calls) == 2 diff --git a/tests/unit/test_observe_ui.py b/tests/unit/test_observe_ui.py index 50f16b42..d4c018da 100644 --- a/tests/unit/test_observe_ui.py +++ b/tests/unit/test_observe_ui.py @@ -708,6 +708,22 @@ def test_render_agents_table_shows_source_kind_and_identity_badges() -> None: assert "source-a" in html +def test_observe_badges_use_filled_high_contrast_treatment() -> None: + css = ui._OBSERVE_STYLES + assert "min-height: 24px;" in css + assert "font-size: 12px;" in css + assert "font-weight: 700;" in css + assert ".observe-badge.observe-tone-ok {" in css + assert ( + "background: color-mix(in srgb, var(--observe-ok) 16%, " + "var(--observe-card-bg));" + ) in css + assert ( + "border-color: color-mix(in srgb, var(--observe-ok) 52%, " + "var(--observe-border));" + ) in css + + def test_render_agents_table_missing_agent_id_shows_identity_unavailable() -> None: html = ui.render_agents_table([_agent(agent_id=None, agent_name=None)]) assert "Identity unavailable" in html @@ -2135,7 +2151,7 @@ def test_script_apply_button_is_the_only_place_draft_becomes_applied() -> None: script = ui._OBSERVE_SCRIPT assert "appliedFilters = draftFilters;" in script # Ensure this assignment only happens inside the submit handler block. - submit_block = script.split('addEventListener("submit"')[1] + submit_block = script.split('form.addEventListener("submit"')[1] assert "appliedFilters = draftFilters;" in submit_block @@ -2174,6 +2190,27 @@ def test_script_observe_query_payload_matches_observe_query_schema() -> None: assert "start: appliedFilters.start" in payload_block assert "end: appliedFilters.end" in payload_block assert "refresh: manual === true" in payload_block + assert "page: currentPage" in payload_block + assert "page_size: currentPageSize" in payload_block + assert "search: currentSearch || null" in payload_block + assert "sort_by: currentSortBy || null" in payload_block + assert "sort_direction: currentSortDirection" in payload_block + + +def test_script_pages_large_views_and_round_trips_safe_state_in_url() -> None: + script = ui._OBSERVE_SCRIPT + assert "function paginationToolbar(bounds)" in script + assert "No rows are available on page " in script + assert " from the highest-ranked results" in script + assert '"Search this view"' in script + assert '"Previous"' in script + assert '"Next"' in script + assert 'params.set("page", String(currentPage))' in script + assert 'params.set("page_size", String(currentPageSize))' in script + assert 'params.set("search", currentSearch)' in script + assert 'params.set("sort_by", currentSortBy)' in script + assert "SERVER_SORT_KEYS" in script + assert '"p95 latency": "p95_latency_ms"' in script def test_script_manual_refresh_sets_refresh_true_and_auto_refresh_sets_refresh_false() -> None: @@ -2185,7 +2222,7 @@ def test_script_manual_refresh_sets_refresh_true_and_auto_refresh_sets_refresh_f assert "fetchObserveData(false);" in script refresh_button_block = script.split('getElementById("observe-refresh-now")')[1].split("}")[0] assert "fetchObserveData(true);" in refresh_button_block - submit_block = script.split('addEventListener("submit"')[1].split("});")[0] + submit_block = script.split('form.addEventListener("submit"')[1].split("});")[0] assert "fetchObserveData(true);" in submit_block @@ -2275,7 +2312,9 @@ def test_script_runtime_kind_tones_mirror_all_six_python_badges() -> None: def test_script_tools_and_runs_render_sources_bounds_and_explained_empty_states() -> None: script = ui._OBSERVE_SCRIPT - agents_block = script.split("function renderAgents(data, diagnostics) {")[1].split("\n }\n")[0] + agents_block = script.split("function renderAgents(data, diagnostics, bounds) {")[1].split( + "\n }\n" + )[0] assert 'sourceCell.title = agent.source_id || ""' in agents_block for function_name, source_field, empty_copy in ( ( @@ -2326,8 +2365,8 @@ def test_script_defines_response_parsing_and_render_dispatch_functions() -> None for name in ( "function renderObserveResponse(body)", "function renderOverview(data, diagnostics)", - "function renderAgents(data, diagnostics)", - "function renderUsage(data, diagnostics)", + "function renderAgents(data, diagnostics, bounds)", + "function renderUsage(data, diagnostics, bounds)", "function renderTools(data, diagnostics, bounds)", "function renderRuns(data, diagnostics, bounds)", "function internalViewFromWire(view)", @@ -2377,8 +2416,8 @@ def test_script_render_dispatch_routes_each_view_to_its_own_renderer_and_field() )[0] assert "var view = internalViewFromWire(body.view) || currentView;" in dispatch_block assert "renderOverview(body.data, body.diagnostics);" in dispatch_block - assert "renderAgents(body.data, body.diagnostics);" in dispatch_block - assert "renderUsage(body.data, body.diagnostics);" in dispatch_block + assert "renderAgents(body.data, body.diagnostics, body.bounds);" in dispatch_block + assert "renderUsage(body.data, body.diagnostics, body.bounds);" in dispatch_block assert "renderTools(body.data, body.diagnostics, body.bounds);" in dispatch_block assert "renderRuns(body.data, body.diagnostics, body.bounds);" in dispatch_block assert "renderCoverage(" not in dispatch_block @@ -2475,7 +2514,8 @@ def test_script_nav_link_click_fetches_the_newly_selected_view() -> None: nav_block = script.split( 'document.querySelectorAll("[data-observe-nav-link]").forEach(function (link) {' )[2].split(" });", 1)[0] - assert "activateView(link.getAttribute" in script + assert 'var nextView = link.getAttribute("data-observe-nav-link");' in nav_block + assert "activateView(nextView);" in nav_block assert "event.preventDefault();" in script assert "pushUrl();" in nav_block assert "fetchObserveData(false);" in nav_block @@ -2588,7 +2628,7 @@ def test_script_defines_agent_detail_functions() -> None: def test_script_renders_agents_table_with_details_column_and_button() -> None: script = ui._OBSERVE_SCRIPT - fn_block = script.split("function renderAgents(data, diagnostics) {")[1].split( + fn_block = script.split("function renderAgents(data, diagnostics, bounds) {")[1].split( "\n }\n" )[0] assert '"Details"' in fn_block