diff --git a/CHANGELOG.md b/CHANGELOG.md index 383a68f9..9ee6a3d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,11 @@ This format follows [Keep a Changelog](https://keepachangelog.com/) and adheres WAF checklist row are gone. ### Fixed +- **Observe now exposes trustworthy aggregates with metadata-only drill-through.** + Overview and Agents use workspace-compatible percentile expressions, Models + excludes agent and tool spans from inference counts, and sortable counters in + Agents, Models, Tools, and Runs expand to bounded operational records without + querying prompts, responses, or tool payloads. - **Observe distinguishes request and dependency telemetry reliably.** KQL now retains the source table while unioning Application Insights records, so Overview and Agents prefer request-level `invoke_agent` records correctly diff --git a/src/agentops/agent/cockpit.py b/src/agentops/agent/cockpit.py index 8856d849..6231710a 100644 --- a/src/agentops/agent/cockpit.py +++ b/src/agentops/agent/cockpit.py @@ -39,6 +39,7 @@ from agentops.core.observe import ( AgentDetailRequest, AttributionQueryRequest, + ObserveDrilldownRequest, ObserveQueryRequest, ObserveScope, TraceContentRequest, @@ -5940,6 +5941,25 @@ async def _api_observe_agent_detail( ) return JSONResponse(result) + @app.post("/api/observe/drilldown") + async def _api_observe_drilldown( + payload: ObserveDrilldownRequest, + user_context: Dict[str, Any] = Depends(_authorize), + ): + filters = payload.filters + if effective_scope is not None: + filters.validate_scope(ObserveScope.model_validate(effective_scope)) + return JSONResponse( + await _service_call( + "drilldown", + view=payload.view, + filters=filters.model_dump(mode="json"), + selector=payload.selector.model_dump(mode="json"), + limit=payload.limit, + user_context=user_context, + ) + ) + @app.post("/api/observe/trace-content") async def _api_observe_trace_content( payload: TraceContentRequest, diff --git a/src/agentops/agent/observe/adapters.py b/src/agentops/agent/observe/adapters.py index b7627de4..71c01f67 100644 --- a/src/agentops/agent/observe/adapters.py +++ b/src/agentops/agent/observe/adapters.py @@ -57,6 +57,7 @@ build_agent_detail_query, build_agents_query, build_department_usage_query, + build_drilldown_query, build_models_query, build_overview_query, build_runs_query, @@ -1023,6 +1024,27 @@ async def query_agent_detail( ), ) + async def query_drilldown( + self, + sources: Sequence[TelemetrySource], + filters: ObserveFilterState, + *, + view: Literal["agents", "models", "tools", "runs"], + selector: Mapping[str, str | None], + limit: int, + ) -> list[SourceResult]: + """Run one bounded metadata-only drill-through query per source.""" + return await self._run( + sources, + lambda source: build_drilldown_query( + filters, + view=view, + selector=selector, + scope_source=source, + limit=limit, + ), + ) + async def query_department_usage( self, sources: Sequence[TelemetrySource], diff --git a/src/agentops/agent/observe/facade.py b/src/agentops/agent/observe/facade.py index 1b22df2b..1656ec9e 100644 --- a/src/agentops/agent/observe/facade.py +++ b/src/agentops/agent/observe/facade.py @@ -70,11 +70,13 @@ from agentops.core.observe import ( AttributionQueryRequest, AttributionResponse, + DrilldownView, GenerativeAIContent, ObservedAgent, ObserveFilterState, ObserveScope, TraceContentRequest, + canonical_arm_id, ) #: Views the facade forwards directly to ``ObserveService.query_view``. Derived @@ -774,6 +776,91 @@ async def _agent_detail_enrichment( portal_links = _agent_detail_portal_links(agent, sources) return trends, portal_links + # -- drilldown -------------------------------------------------------- + + async def drilldown( + self, + *, + view: str, + filters: Mapping[str, Any], + selector: Mapping[str, str | None], + limit: int = 50, + user_context: Mapping[str, Any] | None = None, + ) -> dict[str, Any]: + """Return bounded metadata-only rows behind an Observe aggregate.""" + filter_state = ObserveFilterState.model_validate(dict(filters)) + inventory = await self._service.get_inventory(self._scope) + source_id = str(selector.get("source_id") or "") + raw_project_resource_id = selector.get("project_resource_id") + project_resource_id = ( + canonical_arm_id(raw_project_resource_id) + if raw_project_resource_id is not None + else None + ) + if project_resource_id is not None and not self._scope.contains(project_resource_id): + raise ValueError("drill-through project is outside Observe scope") + sources = [ + source + for source in inventory.telemetry_sources + if source.state == "available" + and source.workspace_id + and source.source_id.casefold() == source_id.casefold() + and ( + project_resource_id is None + or project_resource_id in source.project_resource_ids + ) + ] + if not sources: + raise ValueError("drill-through source is not available in Observe scope") + query_drilldown = getattr(self._query_client, "query_drilldown", None) + if not callable(query_drilldown): + raise RuntimeError("Observe drill-through is unavailable for this query client") + + source_results = await query_drilldown( + sources, + filter_state, + view=cast(DrilldownView, view), + selector=selector, + limit=limit, + ) + source_by_id = {source.source_id: source for source in sources} + rows: list[dict[str, Any]] = [] + source_failures: list[dict[str, str]] = [] + for result in source_results: + if result.status not in ("success", "partial"): + source_failures.append( + {"source_id": result.source_id, "status": result.status} + ) + continue + if result.status == "partial": + source_failures.append( + {"source_id": result.source_id, "status": result.status} + ) + source = source_by_id.get(result.source_id) + for raw_row in result.tables or []: + row = dict(raw_row) + row["source_id"] = result.source_id + row["source_resource_id"] = ( + source.resource_id if source is not None else None + ) + timestamp = row.get("timestamp") + if isinstance(timestamp, datetime): + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=timezone.utc) + row["timestamp"] = timestamp.astimezone(timezone.utc).isoformat() + rows.append(row) + + rows.sort(key=lambda row: str(row.get("timestamp") or ""), reverse=True) + truncated = len(rows) > limit + return { + "view": view, + "data": _serialize_data(rows[:limit]), + "metadata_only": True, + "truncated": truncated, + "complete": not source_failures, + "source_failures": source_failures, + } + # -- trace_content ----------------------------------------------------- async def trace_content( diff --git a/src/agentops/agent/observe/queries.py b/src/agentops/agent/observe/queries.py index d375c499..5abf496c 100644 --- a/src/agentops/agent/observe/queries.py +++ b/src/agentops/agent/observe/queries.py @@ -263,33 +263,35 @@ def build_overview_query( filters: ObserveFilterState, *, scope_source: TelemetrySource | None = None ) -> str: """Aggregate agent invocations without counting internal HTTP/model spans.""" - lines = [ + base_lines = [ _TELEMETRY_TABLES, _time_window_clause(filters), *_dimension_filters(filters, scope_source), *_agent_extend_clauses(), - '| extend is_request_invocation = TelemetryTable == "AppRequests" and ' - 'operation_name == "invoke_agent", ' - 'is_dependency_invocation = TelemetryTable == "AppDependencies" and ' - 'operation_name == "invoke_agent"', - "| summarize request_invocations = countif(is_request_invocation), " - "dependency_invocations = countif(is_dependency_invocation), " - "request_failures = countif(is_request_invocation and Success == false), " - "dependency_failures = countif(is_dependency_invocation and Success == false), " - "request_avg_latency_ms = avgif(DurationMs, is_request_invocation), " - "dependency_avg_latency_ms = avgif(DurationMs, is_dependency_invocation), " - "request_p95_latency_ms = percentileif(DurationMs, 95, is_request_invocation), " - "dependency_p95_latency_ms = percentileif(DurationMs, 95, is_dependency_invocation)", - "| extend invocations = iff(request_invocations > 0, " - "request_invocations, dependency_invocations), " - "failures = iff(request_invocations > 0, request_failures, dependency_failures), " - "avg_latency_ms = iff(request_invocations > 0, " - "request_avg_latency_ms, dependency_avg_latency_ms), " - "p95_latency_ms = iff(request_invocations > 0, " - "request_p95_latency_ms, dependency_p95_latency_ms)", - "| project invocations, failures, avg_latency_ms, p95_latency_ms", + '| where operation_name == "invoke_agent"', + '| extend is_request_invocation = TelemetryTable endswith "AppRequests", ' + 'is_dependency_invocation = TelemetryTable endswith "AppDependencies"', + "| where is_request_invocation or is_dependency_invocation", ] - return "\n".join(lines) + return "\n".join( + [ + f"let candidates = materialize({base_lines[0]}", + *base_lines[1:], + ");", + "let preferences = candidates", + "| summarize has_request = countif(is_request_invocation) > 0 " + "by project_resource_id, agent_key;", + "candidates", + "| join kind=leftouter preferences on project_resource_id, agent_key", + "| where (has_request and is_request_invocation) or " + "(not(has_request) and is_dependency_invocation)", + "| summarize invocations = count(), " + "failures = countif(Success == false), " + "avg_latency_ms = avg(DurationMs), " + "p95_latency_ms = percentile(DurationMs, 95)", + "| project invocations, failures, avg_latency_ms, p95_latency_ms", + ] + ) def build_agents_query( @@ -301,16 +303,18 @@ def build_agents_query( _time_window_clause(filters), *_dimension_filters(filters, scope_source), *_agent_extend_clauses(), - '| extend is_request_invocation = TelemetryTable == "AppRequests" and ' + '| extend is_request_invocation = TelemetryTable endswith "AppRequests" and ' 'operation_name == "invoke_agent", ' - 'is_dependency_invocation = TelemetryTable == "AppDependencies" and ' + 'is_dependency_invocation = TelemetryTable endswith "AppDependencies" and ' 'operation_name == "invoke_agent"', "| summarize request_invocations = countif(is_request_invocation), " "dependency_invocations = countif(is_dependency_invocation), " "request_failures = countif(is_request_invocation and Success == false), " "dependency_failures = countif(is_dependency_invocation and Success == false), " - "request_p95_latency_ms = percentileif(DurationMs, 95, is_request_invocation), " - "dependency_p95_latency_ms = percentileif(DurationMs, 95, is_dependency_invocation), " + "request_p95_latency_ms = percentile(" + "iff(is_request_invocation, DurationMs, real(null)), 95), " + "dependency_p95_latency_ms = percentile(" + "iff(is_dependency_invocation, DurationMs, real(null)), 95), " "input_tokens = sum(input_tokens), " "output_tokens = sum(output_tokens), " "last_seen = max(TimeGenerated), " @@ -345,6 +349,7 @@ def build_models_query( '| extend deployment = tostring(Properties["gen_ai.request.deployment"])', *_token_class_extend_clauses(), "| where isnotempty(model) or isnotempty(deployment)", + '| where operation_name !in ("invoke_agent", "execute_tool")', ] summary_lines = [ "| summarize requests = count(), " @@ -551,6 +556,110 @@ def build_runs_query( return _bounded_aggregate(aggregate_lines, order_by="last_activity_at") +def build_drilldown_query( + filters: ObserveFilterState, + *, + view: Literal["agents", "models", "tools", "runs"], + selector: Mapping[str, str | None], + scope_source: TelemetrySource | None = None, + limit: int = 50, +) -> str: + """Return bounded, metadata-only telemetry rows behind one aggregate.""" + if limit < 1 or limit > 100: + raise ValueError("drill-through limit must be between 1 and 100") + + base_lines = [ + _TELEMETRY_TABLES, + _time_window_clause(filters), + *_dimension_filters(filters, scope_source), + *_agent_extend_clauses(), + '| extend deployment = tostring(Properties["gen_ai.request.deployment"]), ' + 'tool_name = tostring(Properties["gen_ai.tool.name"]), ' + 'conversation_id = tostring(Properties["gen_ai.conversation.id"]), ' + 'foundry_thread_id = tostring(Properties["gen_ai.thread.id"])', + "| extend run_key = iff(isnotempty(conversation_id), conversation_id, " + "iff(isnotempty(foundry_thread_id), foundry_thread_id, tostring(OperationId)))", + ] + project_resource_id = selector.get("project_resource_id") + if project_resource_id: + base_lines.append( + "| where tolower(project_resource_id) == " + f"'{_kql_escape(project_resource_id.lower())}'" + ) + else: + base_lines.append("| where isempty(project_resource_id)") + + if view == "agents": + agent_key = selector.get("agent_key") + if not agent_key: + raise ValueError("agent drill-through requires agent_key") + base_lines.extend( + [ + f"| where agent_key == '{_kql_escape(agent_key)}'", + '| where operation_name == "invoke_agent"', + ] + ) + elif view == "models": + model = selector.get("model") + deployment = selector.get("deployment") + if not model and not deployment: + raise ValueError("model drill-through requires model or deployment") + if model: + base_lines.append(f"| where model == '{_kql_escape(model)}'") + if deployment: + base_lines.append(f"| where deployment == '{_kql_escape(deployment)}'") + base_lines.append('| where operation_name !in ("invoke_agent", "execute_tool")') + elif view == "tools": + tool_name = selector.get("tool_name") + if not tool_name: + raise ValueError("tool drill-through requires tool_name") + base_lines.append(f"| where tool_name == '{_kql_escape(tool_name)}'") + if selector.get("agent_key"): + base_lines.append( + f"| where agent_key == '{_kql_escape(selector['agent_key'] or '')}'" + ) + elif view == "runs": + run_key = selector.get("run_key") + if not run_key: + raise ValueError("run drill-through requires run_key") + base_lines.append(f"| where run_key == '{_kql_escape(run_key)}'") + if selector.get("agent_key"): + base_lines.append( + f"| where agent_key == '{_kql_escape(selector['agent_key'] or '')}'" + ) + else: + raise ValueError(f"unsupported drill-through view: {view}") + + projection = [ + "| project timestamp = TimeGenerated, " + 'telemetry_type = iff(TelemetryTable endswith "AppRequests", ' + '"request", "dependency"), ' + "operation_name, trace_id = tostring(OperationId), " + "span_id = tostring(Id), parent_span_id = tostring(ParentId), " + "agent_id, agent_name, model, deployment, tool_name, " + "success = Success, duration_ms = DurationMs", + "| sort by timestamp desc", + f"| take {limit + 1}", + ] + + if view != "agents": + return "\n".join([*base_lines, *projection]) + + return "\n".join( + [ + f"let selected = materialize({base_lines[0]}", + *base_lines[1:], + ");", + "let has_request_rows = toscalar(" + 'selected | where TelemetryTable endswith "AppRequests" | count) > 0;', + "selected", + "| where not(has_request_rows) or " + 'TelemetryTable endswith "AppRequests"', + *projection, + ] + ) + + def build_usage_query( filters: ObserveFilterState, *, scope_source: TelemetrySource | None = None ) -> str: diff --git a/src/agentops/agent/observe/service.py b/src/agentops/agent/observe/service.py index a574cdd4..0f5a660f 100644 --- a/src/agentops/agent/observe/service.py +++ b/src/agentops/agent/observe/service.py @@ -842,6 +842,7 @@ def is_true(value: Any) -> bool: if is_true(row.get(f"{field}_partial")) ) return ModelUsage( + source_id=source.source_id, project_resource_id=project_resource_id, agent_id=row.get("agent_id") or None, deployment=row.get("deployment") or None, diff --git a/src/agentops/agent/observe/ui.py b/src/agentops/agent/observe/ui.py index 3543fff0..02330670 100644 --- a/src/agentops/agent/observe/ui.py +++ b/src/agentops/agent/observe/ui.py @@ -2817,6 +2817,39 @@ def render_trace_detail_shell( outline-offset: 3px; } tbody tr:hover td { background: color-mix(in srgb, var(--observe-fg) 4%, transparent); } +.observe-drilldown-button { + appearance: none; + background: transparent; + border: 0; + color: var(--observe-accent); + cursor: pointer; + font: inherit; + font-weight: 650; + padding: 0; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--observe-accent) 45%, transparent); + text-underline-offset: 3px; +} +.observe-drilldown-button:hover { text-decoration-color: currentColor; } +.observe-drilldown-button:focus-visible { + border-radius: 3px; + outline: 2px solid var(--observe-accent); + outline-offset: 3px; +} +.observe-drilldown-row > td { + background: color-mix(in srgb, var(--observe-accent) 4%, var(--observe-surface)); + padding: 14px; +} +.observe-drilldown-panel { + border-left: 3px solid var(--observe-accent); + padding-left: 12px; +} +.observe-drilldown-panel > p { margin: 0 0 10px; } +.observe-drilldown-table { + background: var(--observe-bg); + border: 1px solid var(--observe-border); +} +.observe-drilldown-table td { word-break: break-word; } /* --- Badges & tones ----------------------------------------------------- */ .observe-badge { @@ -3484,13 +3517,23 @@ def render_trace_detail_shell( button.title = "Sort by " + label; button.setAttribute("aria-label", "Sort by " + label); button.addEventListener("click", function () { + body.querySelectorAll("[data-observe-drilldown-row]").forEach(function (detailRow) { + detailRow.remove(); + }); + body.querySelectorAll(".observe-drilldown-button").forEach(function (detailButton) { + detailButton.setAttribute("aria-expanded", "false"); + }); var direction = header.getAttribute("aria-sort") === "ascending" ? "descending" : "ascending"; headers.forEach(function (other) { other.setAttribute("aria-sort", other === header ? direction : "none"); }); - var rows = Array.prototype.slice.call(body.rows).map(function (row, index) { + var rows = Array.prototype.slice.call(body.rows) + .filter(function (row) { + return row.dataset.observeDrilldownRow !== "true"; + }) + .map(function (row, index) { return { row: row, index: index, value: sortableCellValue(row.cells[columnIndex] || row) }; }); rows.sort(function (left, right) { @@ -3559,6 +3602,142 @@ def render_trace_detail_shell( return table; } + function drilldownFilters() { + return { + foundry_resource_id: appliedFilters.foundry_resource_id || null, + project_resource_id: appliedFilters.project_resource_id || null, + agent_id: appliedFilters.agent_id || null, + model: appliedFilters.model || null, + start: appliedFilters.start, + end: appliedFilters.end, + }; + } + + function renderDrilldownRows(body) { + var rows = body && Array.isArray(body.data) ? body.data : []; + if (!rows.length) { + if (body && body.complete === false) { + return emptyStateNode("Activity details could not be loaded completely. Refresh and try again."); + } + return emptyStateNode("No matching activity was found for this row."); + } + var tableRows = rows.map(function (row) { + row = row || {}; + var status = row.success === true + ? "Succeeded" + : (row.success === false ? "Failed" : "Not reported"); + return [ + row.timestamp || "Not reported", + row.telemetry_type || "Not reported", + row.operation_name || "Not reported", + row.agent_name || row.agent_id || "Not reported", + row.model || "Not reported", + row.deployment || "Not reported", + row.tool_name || "Not reported", + status, + renderMaybeMissing(row.duration_ms, { suffix: " ms", missingText: "Not measured" }), + row.trace_id || "Not reported", + ]; + }); + return buildDataTable( + "observe-drilldown-table", + "Metadata-only activity for the selected aggregate", + ["Time", "Type", "Operation", "Agent", "Model", "Deployment", "Tool", "Status", "Duration", "Trace ID"], + tableRows + ); + } + + function toggleDrilldown(button, view, selector) { + var parentRow = button.closest("tr"); + if (!parentRow || !parentRow.parentNode) { + return; + } + var existing = parentRow.nextElementSibling; + if (existing && existing.dataset.observeDrilldownRow === "true") { + existing.remove(); + button.setAttribute("aria-expanded", "false"); + return; + } + + parentRow.parentNode.querySelectorAll("[data-observe-drilldown-row]").forEach(function (row) { + row.remove(); + }); + parentRow.parentNode.querySelectorAll(".observe-drilldown-button").forEach(function (other) { + other.setAttribute("aria-expanded", "false"); + }); + + var detailRow = document.createElement("tr"); + detailRow.className = "observe-drilldown-row"; + detailRow.dataset.observeDrilldownRow = "true"; + var detailCell = document.createElement("td"); + detailCell.colSpan = parentRow.cells.length; + var panel = makeEl("div", "observe-drilldown-panel"); + panel.appendChild(makeEl("p", "observe-hint", "Loading activity metadata\u2026")); + detailCell.appendChild(panel); + detailRow.appendChild(detailCell); + parentRow.parentNode.insertBefore(detailRow, parentRow.nextSibling); + button.setAttribute("aria-expanded", "true"); + + fetch("/api/observe/drilldown", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + view: view, + filters: drilldownFilters(), + selector: selector, + limit: 50, + }), + }) + .then(function (response) { + if (!response.ok) { + throw new Error("drill-through request failed"); + } + return response.json(); + }) + .then(function (body) { + if (!detailRow.isConnected) { + return; + } + clearChildren(panel); + panel.appendChild( + makeEl( + "p", + "observe-hint", + body.complete === false + ? "Some activity sources could not be loaded. The records shown may be incomplete." + : body.truncated + ? "Showing the 50 most recent metadata records." + : "Metadata only. Prompts, responses, and tool payloads are not loaded." + ) + ); + panel.appendChild(renderDrilldownRows(body)); + }) + .catch(function () { + if (!detailRow.isConnected) { + return; + } + clearChildren(panel); + panel.appendChild( + emptyStateNode("Activity details could not be loaded. Refresh and try again.") + ); + }); + } + + function buildDrilldownButton(view, selector, value, label) { + var button = makeEl( + "button", + "observe-drilldown-button", + value === undefined || value === null ? "View" : String(value) + ); + button.type = "button"; + button.setAttribute("aria-expanded", "false"); + button.setAttribute("aria-label", label || "View activity details"); + button.addEventListener("click", function () { + toggleDrilldown(button, view, selector); + }); + return button; + } + // The exact key(s) each per-view renderer reads from `data` are a // defensive convention (no backend `service.py` implementation exists yet // to confirm the wire shape against): each helper accepts either a bare @@ -3666,7 +3845,16 @@ def render_trace_detail_shell( sourceCell, agent.model || "Not reported", renderLastSeenJs(agent.last_seen), - renderMaybeMissing(agent.invocations), + buildDrilldownButton( + "agents", + { + source_id: agent.source_id, + project_resource_id: agent.project_resource_id || null, + agent_key: agentKeyFor(agent), + }, + agent.invocations, + "View " + String(agent.invocations || 0) + " invocations for " + (agent.agent_name || agent.agent_id || "this agent") + ), renderFailureRate(agent.invocations, agent.failures), renderMaybeMissing(agent.p95_latency_ms, { suffix: " ms" }), renderMaybeMissing(agent.input_tokens), @@ -3968,7 +4156,17 @@ def render_trace_detail_shell( return [ entry.model || entry.deployment || "Not reported", entry.deployment || "Not reported", - renderMaybeMissing(entry.requests), + buildDrilldownButton( + "models", + { + source_id: entry.source_id, + project_resource_id: entry.project_resource_id || null, + model: entry.model || null, + deployment: entry.deployment || null, + }, + entry.requests, + "View " + String(entry.requests || 0) + " requests for " + (entry.model || entry.deployment || "this model") + ), renderFailureRate(entry.requests, entry.failures), renderMaybeMissing(entry.p95_latency_ms, { suffix: " ms" }), renderMaybeMissing(entry.input_tokens), @@ -4011,7 +4209,17 @@ def render_trace_detail_shell( tool.source_id || "Not reported", renderSourceKindBadge(tool.source_kind), renderLastSeenJs(tool.last_seen), - renderMaybeMissing(tool.invocations), + buildDrilldownButton( + "tools", + { + source_id: tool.source_id, + project_resource_id: tool.project_resource_id || null, + tool_name: tool.tool_name, + agent_key: tool.agent_key || null, + }, + tool.invocations, + "View " + String(tool.invocations || 0) + " invocations for " + (tool.tool_name || "this tool") + ), renderMaybeMissing(tool.failures), renderMaybeMissing(tool.p95_latency_ms, { suffix: " ms", missingText: "Not measured" }), ]; @@ -4046,7 +4254,17 @@ def render_trace_detail_shell( run.started_at || "Not reported", renderMaybeMissing(run.duration_ms, { suffix: " ms" }), run.status || "Not reported", - renderMaybeMissing(run.turns), + buildDrilldownButton( + "runs", + { + source_id: run.source_id, + project_resource_id: run.project_resource_id || null, + run_key: run.run_key, + agent_key: run.agent_key || null, + }, + run.turns, + "View activity for run " + (run.run_key || "") + ), renderMaybeMissing(run.tool_invocations), renderMaybeMissing(run.input_tokens, { missingText: "Not available" }), renderMaybeMissing(run.output_tokens, { missingText: "Not available" }), diff --git a/src/agentops/core/observe.py b/src/agentops/core/observe.py index 8692ed0d..7263bdd6 100644 --- a/src/agentops/core/observe.py +++ b/src/agentops/core/observe.py @@ -17,6 +17,7 @@ ObserveView = Literal[ "overview", "agents", "models", "coverage", "tools", "runs", "cost" ] +DrilldownView = Literal["agents", "models", "tools", "runs"] CostBreakdown = Literal["agents", "tools", "runs"] AllocationKey = Literal[ "weighted_tokens", @@ -433,6 +434,40 @@ class AgentDetailRequest(ContractModel): refresh: bool = False +class ObserveDrilldownSelector(ContractModel): + source_id: str = Field(min_length=1, max_length=2048) + project_resource_id: str | None + agent_key: str | None = Field(default=None, min_length=1, max_length=512) + model: str | None = Field(default=None, min_length=1, max_length=512) + deployment: str | None = Field(default=None, min_length=1, max_length=512) + tool_name: str | None = Field(default=None, min_length=1, max_length=512) + run_key: str | None = Field(default=None, min_length=1, max_length=512) + + _canonicalize_project = field_validator("project_resource_id", mode="before")( + lambda value: canonical_arm_id(value) if isinstance(value, str) else value + ) + + +class ObserveDrilldownRequest(ContractModel): + view: DrilldownView + filters: ObserveFilterState + selector: ObserveDrilldownSelector + limit: int = Field(default=50, ge=1, le=100) + + @model_validator(mode="after") + def _validate_selector_for_view(self) -> "ObserveDrilldownRequest": + selector = self.selector + valid = { + "agents": selector.agent_key is not None, + "models": selector.model is not None or selector.deployment is not None, + "tools": selector.tool_name is not None, + "runs": selector.run_key is not None, + }[self.view] + if not valid: + raise ValueError(f"{self.view} drill-through requires its row identifier") + return self + + class TraceContentRequest(ContractModel): source_resource_id: str trace_id: str = Field(min_length=1) @@ -487,6 +522,7 @@ def _failures_not_greater_than_invocations(self) -> "ObservedAgent": class ModelUsage(ContractModel): + source_id: str | None = Field(default=None, min_length=1) project_resource_id: str | None = None agent_id: str | None = None deployment: str | None = None diff --git a/tests/integration/test_cockpit_hosted.py b/tests/integration/test_cockpit_hosted.py index 0d07e42f..e2f3d644 100644 --- a/tests/integration/test_cockpit_hosted.py +++ b/tests/integration/test_cockpit_hosted.py @@ -97,6 +97,23 @@ def agent_detail( "trends": [], } + def drilldown( + self, + *, + view: str, + filters: dict[str, Any], + selector: dict[str, Any], + limit: int, + user_context: dict[str, Any], + ) -> dict[str, Any]: + return { + "view": view, + "data": [{"operation_name": "execute_tool", **selector}], + "metadata_only": True, + "truncated": False, + "limit": limit, + } + def trace_content( self, *, @@ -507,12 +524,50 @@ def test_observe_routes_are_present_in_application_schema() -> None: for path in ( "/api/observe/discovery", "/api/observe/query", + "/api/observe/drilldown", "/api/observe/agent-detail", "/api/observe/trace-content", ): assert path in schema["paths"] +def test_observe_drilldown_returns_metadata_only_rows() -> None: + client = _hosted_client() + response = client.post( + "/api/observe/drilldown", + headers={"x-ms-client-principal": "allowed"}, + json={ + "view": "tools", + "filters": { + "start": "2026-08-20T00:00:00Z", + "end": "2026-08-21T00:00:00Z", + }, + "selector": { + "source_id": "source-1", + "project_resource_id": ( + "/subscriptions/00000000-0000-0000-0000-000000000000/" + "resourceGroups/rg/providers/Microsoft.CognitiveServices/" + "accounts/account/projects/project" + ), + "tool_name": "weather", + }, + "limit": 25, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["metadata_only"] is True + assert payload["data"][0]["operation_name"] == "execute_tool" + assert payload["data"][0]["source_id"] == "source-1" + assert payload["data"][0]["project_resource_id"] == ( + "/subscriptions/00000000-0000-0000-0000-000000000000/" + "resourcegroups/rg/providers/microsoft.cognitiveservices/" + "accounts/account/projects/project" + ) + assert payload["data"][0]["tool_name"] == "weather" + + def test_hosted_mode_composes_default_auth_and_observe_factories(monkeypatch) -> None: service = _FakeObserveService() calls: dict[str, Any] = {} diff --git a/tests/unit/__snapshots__/observe_overview.html b/tests/unit/__snapshots__/observe_overview.html index 0b1a56c2..5581a081 100644 --- a/tests/unit/__snapshots__/observe_overview.html +++ b/tests/unit/__snapshots__/observe_overview.html @@ -466,6 +466,39 @@ outline-offset: 3px; } tbody tr:hover td { background: color-mix(in srgb, var(--observe-fg) 4%, transparent); } +.observe-drilldown-button { + appearance: none; + background: transparent; + border: 0; + color: var(--observe-accent); + cursor: pointer; + font: inherit; + font-weight: 650; + padding: 0; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--observe-accent) 45%, transparent); + text-underline-offset: 3px; +} +.observe-drilldown-button:hover { text-decoration-color: currentColor; } +.observe-drilldown-button:focus-visible { + border-radius: 3px; + outline: 2px solid var(--observe-accent); + outline-offset: 3px; +} +.observe-drilldown-row > td { + background: color-mix(in srgb, var(--observe-accent) 4%, var(--observe-surface)); + padding: 14px; +} +.observe-drilldown-panel { + border-left: 3px solid var(--observe-accent); + padding-left: 12px; +} +.observe-drilldown-panel > p { margin: 0 0 10px; } +.observe-drilldown-table { + background: var(--observe-bg); + border: 1px solid var(--observe-border); +} +.observe-drilldown-table td { word-break: break-word; } /* --- Badges & tones ----------------------------------------------------- */ .observe-badge { @@ -1199,13 +1232,23 @@

Runs

button.title = "Sort by " + label; button.setAttribute("aria-label", "Sort by " + label); button.addEventListener("click", function () { + body.querySelectorAll("[data-observe-drilldown-row]").forEach(function (detailRow) { + detailRow.remove(); + }); + body.querySelectorAll(".observe-drilldown-button").forEach(function (detailButton) { + detailButton.setAttribute("aria-expanded", "false"); + }); var direction = header.getAttribute("aria-sort") === "ascending" ? "descending" : "ascending"; headers.forEach(function (other) { other.setAttribute("aria-sort", other === header ? direction : "none"); }); - var rows = Array.prototype.slice.call(body.rows).map(function (row, index) { + var rows = Array.prototype.slice.call(body.rows) + .filter(function (row) { + return row.dataset.observeDrilldownRow !== "true"; + }) + .map(function (row, index) { return { row: row, index: index, value: sortableCellValue(row.cells[columnIndex] || row) }; }); rows.sort(function (left, right) { @@ -1274,6 +1317,142 @@

Runs

return table; } + function drilldownFilters() { + return { + foundry_resource_id: appliedFilters.foundry_resource_id || null, + project_resource_id: appliedFilters.project_resource_id || null, + agent_id: appliedFilters.agent_id || null, + model: appliedFilters.model || null, + start: appliedFilters.start, + end: appliedFilters.end, + }; + } + + function renderDrilldownRows(body) { + var rows = body && Array.isArray(body.data) ? body.data : []; + if (!rows.length) { + if (body && body.complete === false) { + return emptyStateNode("Activity details could not be loaded completely. Refresh and try again."); + } + return emptyStateNode("No matching activity was found for this row."); + } + var tableRows = rows.map(function (row) { + row = row || {}; + var status = row.success === true + ? "Succeeded" + : (row.success === false ? "Failed" : "Not reported"); + return [ + row.timestamp || "Not reported", + row.telemetry_type || "Not reported", + row.operation_name || "Not reported", + row.agent_name || row.agent_id || "Not reported", + row.model || "Not reported", + row.deployment || "Not reported", + row.tool_name || "Not reported", + status, + renderMaybeMissing(row.duration_ms, { suffix: " ms", missingText: "Not measured" }), + row.trace_id || "Not reported", + ]; + }); + return buildDataTable( + "observe-drilldown-table", + "Metadata-only activity for the selected aggregate", + ["Time", "Type", "Operation", "Agent", "Model", "Deployment", "Tool", "Status", "Duration", "Trace ID"], + tableRows + ); + } + + function toggleDrilldown(button, view, selector) { + var parentRow = button.closest("tr"); + if (!parentRow || !parentRow.parentNode) { + return; + } + var existing = parentRow.nextElementSibling; + if (existing && existing.dataset.observeDrilldownRow === "true") { + existing.remove(); + button.setAttribute("aria-expanded", "false"); + return; + } + + parentRow.parentNode.querySelectorAll("[data-observe-drilldown-row]").forEach(function (row) { + row.remove(); + }); + parentRow.parentNode.querySelectorAll(".observe-drilldown-button").forEach(function (other) { + other.setAttribute("aria-expanded", "false"); + }); + + var detailRow = document.createElement("tr"); + detailRow.className = "observe-drilldown-row"; + detailRow.dataset.observeDrilldownRow = "true"; + var detailCell = document.createElement("td"); + detailCell.colSpan = parentRow.cells.length; + var panel = makeEl("div", "observe-drilldown-panel"); + panel.appendChild(makeEl("p", "observe-hint", "Loading activity metadata…")); + detailCell.appendChild(panel); + detailRow.appendChild(detailCell); + parentRow.parentNode.insertBefore(detailRow, parentRow.nextSibling); + button.setAttribute("aria-expanded", "true"); + + fetch("/api/observe/drilldown", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + view: view, + filters: drilldownFilters(), + selector: selector, + limit: 50, + }), + }) + .then(function (response) { + if (!response.ok) { + throw new Error("drill-through request failed"); + } + return response.json(); + }) + .then(function (body) { + if (!detailRow.isConnected) { + return; + } + clearChildren(panel); + panel.appendChild( + makeEl( + "p", + "observe-hint", + body.complete === false + ? "Some activity sources could not be loaded. The records shown may be incomplete." + : body.truncated + ? "Showing the 50 most recent metadata records." + : "Metadata only. Prompts, responses, and tool payloads are not loaded." + ) + ); + panel.appendChild(renderDrilldownRows(body)); + }) + .catch(function () { + if (!detailRow.isConnected) { + return; + } + clearChildren(panel); + panel.appendChild( + emptyStateNode("Activity details could not be loaded. Refresh and try again.") + ); + }); + } + + function buildDrilldownButton(view, selector, value, label) { + var button = makeEl( + "button", + "observe-drilldown-button", + value === undefined || value === null ? "View" : String(value) + ); + button.type = "button"; + button.setAttribute("aria-expanded", "false"); + button.setAttribute("aria-label", label || "View activity details"); + button.addEventListener("click", function () { + toggleDrilldown(button, view, selector); + }); + return button; + } + // The exact key(s) each per-view renderer reads from `data` are a // defensive convention (no backend `service.py` implementation exists yet // to confirm the wire shape against): each helper accepts either a bare @@ -1381,7 +1560,16 @@

Runs

sourceCell, agent.model || "Not reported", renderLastSeenJs(agent.last_seen), - renderMaybeMissing(agent.invocations), + buildDrilldownButton( + "agents", + { + source_id: agent.source_id, + project_resource_id: agent.project_resource_id || null, + agent_key: agentKeyFor(agent), + }, + agent.invocations, + "View " + String(agent.invocations || 0) + " invocations for " + (agent.agent_name || agent.agent_id || "this agent") + ), renderFailureRate(agent.invocations, agent.failures), renderMaybeMissing(agent.p95_latency_ms, { suffix: " ms" }), renderMaybeMissing(agent.input_tokens), @@ -1683,7 +1871,17 @@

Runs

return [ entry.model || entry.deployment || "Not reported", entry.deployment || "Not reported", - renderMaybeMissing(entry.requests), + buildDrilldownButton( + "models", + { + source_id: entry.source_id, + project_resource_id: entry.project_resource_id || null, + model: entry.model || null, + deployment: entry.deployment || null, + }, + entry.requests, + "View " + String(entry.requests || 0) + " requests for " + (entry.model || entry.deployment || "this model") + ), renderFailureRate(entry.requests, entry.failures), renderMaybeMissing(entry.p95_latency_ms, { suffix: " ms" }), renderMaybeMissing(entry.input_tokens), @@ -1726,7 +1924,17 @@

Runs

tool.source_id || "Not reported", renderSourceKindBadge(tool.source_kind), renderLastSeenJs(tool.last_seen), - renderMaybeMissing(tool.invocations), + buildDrilldownButton( + "tools", + { + source_id: tool.source_id, + project_resource_id: tool.project_resource_id || null, + tool_name: tool.tool_name, + agent_key: tool.agent_key || null, + }, + tool.invocations, + "View " + String(tool.invocations || 0) + " invocations for " + (tool.tool_name || "this tool") + ), renderMaybeMissing(tool.failures), renderMaybeMissing(tool.p95_latency_ms, { suffix: " ms", missingText: "Not measured" }), ]; @@ -1761,7 +1969,17 @@

Runs

run.started_at || "Not reported", renderMaybeMissing(run.duration_ms, { suffix: " ms" }), run.status || "Not reported", - renderMaybeMissing(run.turns), + buildDrilldownButton( + "runs", + { + source_id: run.source_id, + project_resource_id: run.project_resource_id || null, + run_key: run.run_key, + agent_key: run.agent_key || null, + }, + run.turns, + "View activity for run " + (run.run_key || "") + ), renderMaybeMissing(run.tool_invocations), renderMaybeMissing(run.input_tokens, { missingText: "Not available" }), renderMaybeMissing(run.output_tokens, { missingText: "Not available" }), diff --git a/tests/unit/__snapshots__/observe_styles.css b/tests/unit/__snapshots__/observe_styles.css index d338088d..bc15c167 100644 --- a/tests/unit/__snapshots__/observe_styles.css +++ b/tests/unit/__snapshots__/observe_styles.css @@ -460,6 +460,39 @@ th[aria-sort="descending"] .observe-sort-button::after { outline-offset: 3px; } tbody tr:hover td { background: color-mix(in srgb, var(--observe-fg) 4%, transparent); } +.observe-drilldown-button { + appearance: none; + background: transparent; + border: 0; + color: var(--observe-accent); + cursor: pointer; + font: inherit; + font-weight: 650; + padding: 0; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--observe-accent) 45%, transparent); + text-underline-offset: 3px; +} +.observe-drilldown-button:hover { text-decoration-color: currentColor; } +.observe-drilldown-button:focus-visible { + border-radius: 3px; + outline: 2px solid var(--observe-accent); + outline-offset: 3px; +} +.observe-drilldown-row > td { + background: color-mix(in srgb, var(--observe-accent) 4%, var(--observe-surface)); + padding: 14px; +} +.observe-drilldown-panel { + border-left: 3px solid var(--observe-accent); + padding-left: 12px; +} +.observe-drilldown-panel > p { margin: 0 0 10px; } +.observe-drilldown-table { + background: var(--observe-bg); + border: 1px solid var(--observe-border); +} +.observe-drilldown-table td { word-break: break-word; } /* --- Badges & tones ----------------------------------------------------- */ .observe-badge { diff --git a/tests/unit/test_observe_adapters.py b/tests/unit/test_observe_adapters.py index 775e7b78..af95b2bf 100644 --- a/tests/unit/test_observe_adapters.py +++ b/tests/unit/test_observe_adapters.py @@ -812,6 +812,35 @@ async def test_azure_query_client_query_agent_detail_uses_bounded_trend_query() assert "p95_latency_ms" in request.query +@pytest.mark.asyncio +async def test_azure_query_client_drilldown_uses_metadata_only_builder() -> None: + client = AzureQueryClient(credential="fake-credential") + fake_logs_client = _FakeLogsClient() + client._logs_client = fake_logs_client + project_id = ( + "/subscriptions/s/resourceGroups/rg/providers/" + "Microsoft.CognitiveServices/accounts/a/projects/project" + ) + + await client.query_drilldown( + [_make_source("source-1")], + _make_filters(), + view="tools", + selector={ + "source_id": "source-1", + "project_resource_id": project_id, + "tool_name": "weather", + }, + limit=25, + ) + + (request,) = fake_logs_client.batches[0] + assert "tool_name == 'weather'" in request.query + assert f"tolower(project_resource_id) == '{project_id.lower()}'" in request.query + assert "| take 26" in request.query + assert "AppGenAIContent" not in request.query + + @pytest.mark.asyncio async def test_azure_query_client_always_applies_source_project_boundary() -> None: client = AzureQueryClient(credential="fake-credential") diff --git a/tests/unit/test_observe_facade.py b/tests/unit/test_observe_facade.py index a7c4fe6d..3edb6396 100644 --- a/tests/unit/test_observe_facade.py +++ b/tests/unit/test_observe_facade.py @@ -116,9 +116,11 @@ def __init__( *, rows: Sequence[Mapping[str, Any]] = (), detail_results: Sequence[SourceResult] | None = None, + drilldown_results: Sequence[SourceResult] | None = None, ) -> None: self.rows = list(rows) self.detail_results = detail_results + self.drilldown_results = drilldown_results self.query_calls: list[str] = [] self.attribution_filters: list[Any] = [] self.detail_calls = 0 @@ -140,6 +142,20 @@ async def query_agent_detail(self, sources, filters, *, agent_key): for source in sources ] + async def query_drilldown(self, sources, filters, **kwargs): + self.query_calls.append("drilldown") + if self.drilldown_results is not None: + return list(self.drilldown_results) + return [ + SourceResult( + source_id=source.source_id, + status="success", + tables=self.rows, + duration_ms=1, + ) + for source in sources + ] + async def query_department_usage(self, sources, filters, **kwargs): self.query_calls.append("department_attribution") self.attribution_filters.append(filters) @@ -1392,6 +1408,72 @@ async def test_agent_detail_returns_none_for_unseen_agent() -> None: assert result is None +@pytest.mark.asyncio +async def test_drilldown_returns_bounded_metadata_rows_with_source_context() -> None: + query_client = FakeQueryClient( + rows=[ + { + "timestamp": datetime(2026, 8, 20, 12, 0, tzinfo=timezone.utc), + "operation_name": "execute_tool", + "tool_name": "weather", + "trace_id": "trace-1", + } + ] + ) + fac = _make_facade(query_client=query_client) + + result = await fac.drilldown( + view="tools", + filters=_filters(), + selector={ + "source_id": "source-1", + "project_resource_id": _PROJECT_ID, + "tool_name": "weather", + }, + limit=50, + ) + + assert result["metadata_only"] is True + assert result["truncated"] is False + assert result["complete"] is True + assert result["source_failures"] == [] + assert result["data"][0]["tool_name"] == "weather" + assert result["data"][0]["source_id"] == "source-1" + assert result["data"][0]["timestamp"] == "2026-08-20T12:00:00+00:00" + assert "content" not in result["data"][0] + assert query_client.query_calls == ["drilldown"] + + +@pytest.mark.asyncio +async def test_drilldown_reports_source_failure_instead_of_empty_success() -> None: + query_client = FakeQueryClient( + drilldown_results=[ + SourceResult( + source_id="source-1", + status="timeout", + reason="deadline exceeded", + ) + ] + ) + fac = _make_facade(query_client=query_client) + + result = await fac.drilldown( + view="tools", + filters=_filters(), + selector={ + "source_id": "source-1", + "project_resource_id": _PROJECT_ID, + "tool_name": "weather", + }, + ) + + assert result["data"] == [] + assert result["complete"] is False + assert result["source_failures"] == [ + {"source_id": "source-1", "status": "timeout"} + ] + + @pytest.mark.asyncio async def test_agent_detail_includes_bounded_trends_and_portal_links() -> None: detail_rows = [ diff --git a/tests/unit/test_observe_models.py b/tests/unit/test_observe_models.py index 05244b42..ac537b73 100644 --- a/tests/unit/test_observe_models.py +++ b/tests/unit/test_observe_models.py @@ -17,6 +17,7 @@ DeploymentJournal, GenerativeAIContent, MutationRecord, + ObserveDrilldownRequest, ObserveFilterState, ObserveQueryRequest, ObserveScope, @@ -271,6 +272,15 @@ def test_observe_api_requests_are_strict_and_canonical() -> None: query = ObserveQueryRequest(view="agents", filters=filters) detail = AgentDetailRequest(agent_key="agent-a", filters=filters, refresh=True) + drilldown = ObserveDrilldownRequest( + view="models", + filters=filters, + selector={ + "source_id": "source-a", + "project_resource_id": PROJECT.upper(), + "model": "gpt-5", + }, + ) content = TraceContentRequest( source_resource_id=PROJECT.upper(), trace_id="trace-a", @@ -282,9 +292,21 @@ def test_observe_api_requests_are_strict_and_canonical() -> None: assert ObserveQueryRequest(view="tools", filters=filters).view == "tools" assert ObserveQueryRequest(view="runs", filters=filters).view == "runs" assert detail.refresh is True + assert drilldown.limit == 50 + assert drilldown.selector.project_resource_id == PROJECT.lower() assert content.source_resource_id == PROJECT.lower() with pytest.raises(ValidationError): ObserveQueryRequest(view="unknown", filters=filters) + with pytest.raises(ValidationError): + ObserveDrilldownRequest( + view="tools", + filters=filters, + selector={ + "source_id": "source-a", + "project_resource_id": PROJECT, + "model": "gpt-5", + }, + ) with pytest.raises(ValidationError): TraceContentRequest( source_resource_id=PROJECT, diff --git a/tests/unit/test_observe_queries.py b/tests/unit/test_observe_queries.py index f7d4e786..c7a45a2e 100644 --- a/tests/unit/test_observe_queries.py +++ b/tests/unit/test_observe_queries.py @@ -20,6 +20,7 @@ SupersededRequestError, build_agent_detail_query, build_department_usage_query, + build_drilldown_query, build_agents_query, build_appgenai_content_query, build_models_query, @@ -163,8 +164,13 @@ def test_overview_query_is_bounded_to_time_window_and_tables() -> None: assert "2024-01-01" in query assert "2024-01-02" in query assert 'operation_name == "invoke_agent"' in query - assert "request_invocations" in query - assert "dependency_invocations" in query + assert 'TelemetryTable endswith "AppRequests"' in query + assert 'TelemetryTable endswith "AppDependencies"' in query + assert "percentileif" not in query + assert "let candidates = materialize(" in query + assert "has_request = countif(is_request_invocation) > 0" in query + assert "p95_latency_ms = percentile(DurationMs, 95)" in query + assert "max(p95_latency_ms)" not in query assert "| project invocations, failures, avg_latency_ms, p95_latency_ms" in query @@ -258,6 +264,7 @@ def test_models_query_projects_and_sums_normalized_token_classes() -> None: def test_models_query_preserves_missing_and_intermittent_class_reporting() -> None: query = build_models_query(_filters()) + assert 'operation_name !in ("invoke_agent", "execute_tool")' in query assert "token_reporting_records = countif(" in query for token_class in TOKEN_CLASS_ALIASES: reporting_records = f"{token_class}_reporting_records" @@ -274,6 +281,52 @@ def test_models_query_preserves_missing_and_intermittent_class_reporting() -> No ) in query +@pytest.mark.parametrize( + ("view", "selector", "expected"), + [ + ("agents", {"agent_key": "agent-a"}, "has_request_rows"), + ("models", {"model": "gpt-5"}, "model == 'gpt-5'"), + ("tools", {"tool_name": "weather"}, "tool_name == 'weather'"), + ("runs", {"run_key": "run-a"}, "run_key == 'run-a'"), + ], +) +def test_drilldown_query_is_bounded_and_metadata_only( + view: Any, selector: dict[str, str], expected: str +) -> None: + selector.update( + source_id="source-1", + project_resource_id=_PROJECT_ARM_ID, + ) + query = build_drilldown_query( + _filters(), + view=view, + selector=selector, + limit=50, + ) + + assert expected in query + assert f"tolower(project_resource_id) == '{_PROJECT_ARM_ID.lower()}'" in query + assert "| take 51" in query + assert "trace_id = tostring(OperationId)" in query + assert "Properties[\"gen_ai.prompt\"]" not in query + assert "AppGenAIContent" not in query + + +def test_tool_drilldown_matches_the_tool_aggregate_semantics() -> None: + query = build_drilldown_query( + _filters(), + view="tools", + selector={ + "source_id": "source-1", + "project_resource_id": _PROJECT_ARM_ID, + "tool_name": "weather", + }, + ) + + assert "tool_name == 'weather'" in query + assert 'operation_name == "execute_tool"' not in query + + def test_models_query_projects_unmapped_usage_attributes_in_one_bounded_query() -> None: query = build_models_query(_filters()) assert "materialize(" in query diff --git a/tests/unit/test_observe_service.py b/tests/unit/test_observe_service.py index c1b1fc08..9821cf81 100644 --- a/tests/unit/test_observe_service.py +++ b/tests/unit/test_observe_service.py @@ -394,6 +394,7 @@ def test_normalize_model_row_reads_deployment_and_tokens() -> None: } usage = normalize_model_row(row, source=source) assert usage.deployment == "gpt-4o-prod" + assert usage.source_id == source.source_id assert usage.project_resource_id == _PROJECT_ID assert usage.requests == 5 assert usage.failures == 1 diff --git a/tests/unit/test_observe_ui.py b/tests/unit/test_observe_ui.py index 3fcace66..dc1d74fe 100644 --- a/tests/unit/test_observe_ui.py +++ b/tests/unit/test_observe_ui.py @@ -744,6 +744,22 @@ def test_render_agents_table_omits_technical_diagnostics_banner() -> None: assert "Sources queried" not in html +def test_drilldown_controls_preserve_source_and_project_scope() -> None: + script = ui._OBSERVE_SCRIPT + for selector_field in ("source_id", "project_resource_id"): + assert f"{selector_field}:" in script + assert "body.complete === false" in script + assert "Some activity sources could not be loaded." in script + assert "No matching activity was found for this row." in script + + +def test_all_observe_table_columns_are_upgraded_to_sortable_headers() -> None: + script = ui._OBSERVE_SCRIPT + assert 'var headers = table.querySelectorAll("thead th")' in script + assert 'makeEl("button", "observe-sort-button", label)' in script + assert 'other.setAttribute("aria-sort", other === header ? direction : "none")' in script + + # --------------------------------------------------------------------------- # Cost allocation (spec 013 T013/T021) # ---------------------------------------------------------------------------