diff --git a/CHANGELOG.md b/CHANGELOG.md index 41a242eb..0b119c4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,13 @@ This format follows [Keep a Changelog](https://keepachangelog.com/) and adheres WAF checklist row are gone. ### Fixed +- **Observe now attributes Foundry agents and presents usable telemetry tables.** + Hosted and prompt agents are classified from their emitted provider metadata, + project attribution recognizes the Foundry project dimension, and Runs returns + one correlated execution instead of separate rows for each internal operation. + Overview counts only agent invocations, token usage is split into sortable + columns, and internal connector diagnostics and the redundant Telemetry + coverage tab no longer appear in the user-facing dashboard. - **Foundry project links now open the configured project instead of the tenant landing page.** In project-observability-only mode there is no cloud evaluation report from which to recover a portal URL, so Cockpit previously fell back to diff --git a/src/agentops/agent/observe/queries.py b/src/agentops/agent/observe/queries.py index ddcc7e7a..6aed5f88 100644 --- a/src/agentops/agent/observe/queries.py +++ b/src/agentops/agent/observe/queries.py @@ -39,13 +39,15 @@ _APPGENAI_TABLE = "AppGenAIContent" _PROJECT_RESOURCE_ID = ( 'tostring(coalesce(Properties["gen_ai.project.id"], ' - 'Properties["gen_ai.azure_ai_project.id"]))' + 'Properties["gen_ai.azure_ai_project.id"], ' + 'Properties["microsoft.foundry.project.id"]))' ) TOKEN_CLASS_ALIASES: dict[str, tuple[str, ...]] = { "cache_read": ( "gen_ai.usage.cache_read.input_tokens", "gen_ai.usage.cache_read_input_tokens", + "gen_ai.usage.cached_tokens", ), "cache_write": ( "gen_ai.usage.cache_write.input_tokens", @@ -169,6 +171,7 @@ def _agent_extend_clauses() -> list[str]: '| extend agent_name = tostring(Properties["gen_ai.agent.name"])', '| extend provider_name = tostring(Properties["gen_ai.provider.name"])', '| extend system = tostring(Properties["gen_ai.system"])', + '| extend operation_name = tostring(Properties["gen_ai.operation.name"])', '| extend model = tostring(coalesce(Properties["gen_ai.request.model"], ' 'Properties["gen_ai.response.model"]))', '| extend input_tokens = toint(Properties["gen_ai.usage.input_tokens"])', @@ -259,16 +262,32 @@ def _token_class_extend_clauses() -> list[str]: def build_overview_query( filters: ObserveFilterState, *, scope_source: TelemetrySource | None = None ) -> str: - """Bounded aggregate invocation/failure/latency query for the overview view.""" + """Aggregate agent invocations without counting internal HTTP/model spans.""" lines = [ _TELEMETRY_TABLES, _time_window_clause(filters), *_dimension_filters(filters, scope_source), - "| where isnotempty(Name)", - "| summarize invocations = count(), " - "failures = countif(Success == false), " - "avg_latency_ms = avg(DurationMs), " - "p95_latency_ms = percentile(DurationMs, 95)", + *_agent_extend_clauses(), + '| extend is_request_invocation = Type == "AppRequests" and ' + 'operation_name == "invoke_agent", ' + 'is_dependency_invocation = Type == "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", ] return "\n".join(lines) @@ -282,9 +301,16 @@ def build_agents_query( _time_window_clause(filters), *_dimension_filters(filters, scope_source), *_agent_extend_clauses(), - "| summarize invocations = count(), " - "failures = countif(Success == false), " - "p95_latency_ms = percentile(DurationMs, 95), " + '| extend is_request_invocation = Type == "AppRequests" and ' + 'operation_name == "invoke_agent", ' + 'is_dependency_invocation = Type == "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), " "input_tokens = sum(input_tokens), " "output_tokens = sum(output_tokens), " "last_seen = max(TimeGenerated), " @@ -294,6 +320,15 @@ def build_agents_query( "system = take_anyif(system, isnotempty(system)), " "model = take_anyif(model, isnotempty(model)) " "by project_resource_id, agent_key", + "| extend invocations = iff(request_invocations > 0, " + "request_invocations, dependency_invocations), " + "failures = iff(request_invocations > 0, request_failures, dependency_failures), " + "p95_latency_ms = iff(request_invocations > 0, " + "request_p95_latency_ms, dependency_p95_latency_ms)", + "| project-away request_invocations, dependency_invocations, " + "request_failures, dependency_failures, request_p95_latency_ms, " + "dependency_p95_latency_ms", + "| where invocations > 0", ] return _bounded_aggregate(aggregate_lines, order_by="invocations") @@ -309,6 +344,7 @@ def build_models_query( *_agent_extend_clauses(), '| extend deployment = tostring(Properties["gen_ai.request.deployment"])', *_token_class_extend_clauses(), + "| where isnotempty(model) or isnotempty(deployment)", ] summary_lines = [ "| summarize requests = count(), " @@ -488,9 +524,12 @@ def build_runs_query( "cache_write_token_reports = countif(isnotnull(cache_write_tokens)), " "reasoning_token_reports = countif(isnotnull(reasoning_tokens)), " "credit_reports = countif(isnotnull(credits)), " - "credit_event_reports = countif(isnotnull(credit_event)) " - "by project_resource_id, agent_key, agent_id, agent_name, provider_name, system, " - "run_key, run_key_kind, operation_name", + "credit_event_reports = countif(isnotnull(credit_event)), " + "agent_id = take_anyif(agent_id, isnotempty(agent_id)), " + "agent_name = take_anyif(agent_name, isnotempty(agent_name)), " + "provider_name = take_anyif(provider_name, isnotempty(provider_name)), " + "system = take_anyif(system, isnotempty(system)) " + "by project_resource_id, agent_key, run_key, run_key_kind", "| extend input_tokens = iff(input_token_reports == 0, long(null), input_tokens), " "output_tokens = iff(output_token_reports == 0, long(null), output_tokens), " "cache_read_tokens = iff(cache_read_token_reports == 0, long(null), " diff --git a/src/agentops/agent/observe/service.py b/src/agentops/agent/observe/service.py index 6262c16f..a574cdd4 100644 --- a/src/agentops/agent/observe/service.py +++ b/src/agentops/agent/observe/service.py @@ -227,6 +227,10 @@ class _CachedView: _HOSTED_AGENT_KINDS = frozenset({"hosted", "container", "foundry_hosted"}) _PROMPT_AGENT_KINDS = frozenset({"prompt", "foundry_prompt"}) +_HOSTED_AGENT_PROVIDERS = frozenset( + {"azure.ai.foundry", "microsoft.agent_framework", "microsoft agent framework"} +) +_PROMPT_AGENT_PROVIDERS = frozenset({"microsoft.foundry", "microsoft foundry"}) def _normalized_runtime_value(value: Any) -> str | None: @@ -345,6 +349,14 @@ def classify_runtime( if _is_copilot_studio_provider(provider_name, system): return "copilot_studio" + provider = _normalized_runtime_value(provider_name) + if provider is not None: + readable_provider = provider.replace("_", " ") + if provider in _HOSTED_AGENT_PROVIDERS or readable_provider in _HOSTED_AGENT_PROVIDERS: + return "foundry_hosted" + if provider in _PROMPT_AGENT_PROVIDERS or readable_provider in _PROMPT_AGENT_PROVIDERS: + return "foundry_prompt" + if agent_id: return _inventory_agent_kind(inventory, agent_id=agent_id) or "unknown" @@ -1808,7 +1820,9 @@ async def query_view( cache_status="hit", ) - inventory = await self.get_inventory(scope, refresh=refresh) + # 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) available_sources = [ source for source in inventory.telemetry_sources @@ -1927,7 +1941,7 @@ async def query_attribution( scope=scope, ).id - inventory = await self.get_inventory(scope, refresh=request.refresh) + inventory = await self.get_inventory(scope) available_sources = [ source for source in inventory.telemetry_sources @@ -2748,7 +2762,7 @@ async def _query_user_usage_attribution( principal_group_ids=groups, ) - inventory = await self.get_inventory(scope, refresh=request.refresh) + inventory = await self.get_inventory(scope) sources = [ source for source in inventory.telemetry_sources @@ -3121,7 +3135,7 @@ async def query_cost( cache_status="hit", ) - inventory = await self.get_inventory(scope, refresh=refresh) + inventory = await self.get_inventory(scope) available_sources = [ source for source in inventory.telemetry_sources @@ -3671,12 +3685,30 @@ def _normalize_view( return runs, coverage # "overview": aggregate totals only, never inferring a zero as failure. - totals = {"invocations": 0, "failures": 0} + totals: dict[str, int | float | None] = { + "invocations": 0, + "failures": 0, + "avg_latency_ms": None, + "p95_latency_ms": None, + } + weighted_latency = 0.0 + latency_invocations = 0 + source_p95_values: list[float] = [] for result in results: rows = list(result.tables or []) for row in rows: - totals["invocations"] += int(row.get("invocations") or 0) - totals["failures"] += int(row.get("failures") or 0) + invocations = int(row.get("invocations") or 0) + totals["invocations"] = int(totals["invocations"] or 0) + invocations + totals["failures"] = int(totals["failures"] or 0) + int( + row.get("failures") or 0 + ) + average = row.get("avg_latency_ms") + if average is not None and invocations > 0: + weighted_latency += float(average) * invocations + latency_invocations += invocations + p95 = row.get("p95_latency_ms") + if p95 is not None: + source_p95_values.append(float(p95)) coverage.append( classify_query_coverage( source_id=result.source_id, @@ -3687,6 +3719,12 @@ def _normalize_view( refreshed_at=refreshed_at, ) ) + if latency_invocations: + totals["avg_latency_ms"] = weighted_latency / latency_invocations + if source_p95_values: + # A percentile cannot be recomputed from per-source aggregates. The + # maximum is a conservative cross-source operational signal. + totals["p95_latency_ms"] = max(source_p95_values) return totals, coverage def _build_diagnostics( diff --git a/src/agentops/agent/observe/ui.py b/src/agentops/agent/observe/ui.py index 4b8160c3..3543fff0 100644 --- a/src/agentops/agent/observe/ui.py +++ b/src/agentops/agent/observe/ui.py @@ -64,7 +64,7 @@ # --------------------------------------------------------------------------- #: Views exposed by the Observe navigation, in display order. -OBSERVE_VIEWS: tuple[str, ...] = ("overview", "agents", "usage", "tools", "runs", "coverage") +OBSERVE_VIEWS: tuple[str, ...] = ("overview", "agents", "usage", "tools", "runs") #: Human-readable labels for each view, used by the nav and page title. OBSERVE_VIEW_LABELS: dict[str, str] = { @@ -73,7 +73,6 @@ "usage": "Models and usage", "tools": "Tools", "runs": "Runs", - "coverage": "Telemetry coverage", } #: Maps each internal ``OBSERVE_VIEWS`` identifier to the ``ObserveQuery.view`` @@ -89,7 +88,6 @@ "usage": "models", "tools": "tools", "runs": "runs", - "coverage": "coverage", } #: The *only* filter keys that may ever be written to the URL query string. @@ -477,6 +475,31 @@ def _render_token_totals( ) +def _observed_token_total(input_tokens: Any, output_tokens: Any) -> int | float | None: + if input_tokens is None and output_tokens is None: + return None + return (input_tokens or 0) + (output_tokens or 0) + + +def _render_additional_token_classes(entry: Any) -> str: + additional = _get(entry, "additional_token_classes", {}) or {} + values = [ + '' + f'{html_escape(name)}: ' + f"{_render_maybe_missing(value)}" + for name, value in additional.items() + ] + if _get(entry, "additional_token_classes_truncated", False): + values.append( + 'Additional classes truncated' + ) + if _get(entry, "token_classes_partial", False): + values.append( + 'Partial class coverage' + ) + return "".join(values) if values else _render_maybe_missing(None) + + def _render_model_token_usage(entry: Any) -> str: classes = ( ("Cache read", "cache_read_tokens"), @@ -953,10 +976,9 @@ def render_overview_cards( matching :func:`render_trend_chart`. Invocation, failure, latency, token and coverage trends are surfaced here. """ - banner = render_diagnostics_banner(diagnostics) if diagnostics is not None else "" if not metrics and not trends: return ( - f'{banner}
No data found for the selected filters.
Token columns show observed usage, not billing data.
| Invocations | Failure rate | p95 latency | -Tokens | +Input tokens | +Output tokens | +Total tokens | {"".join(rows)} @@ -1299,7 +1326,6 @@ def render_department_view( if attribution is None: return ( '
|---|
No cost allocation data reported.
' f"{supplemental}" f'{html_escape(COST_BREAKDOWN_WARNING)}
' f'{html_escape(COST_DISCLAIMER)}
{html_escape(COST_BREAKDOWN_WARNING)}
' f"No tool activity was found for the selected filters. ' - 'Tool attribution may not be reported; check Telemetry coverage for details.
| {html_escape(_get(run, 'status') or 'Not reported')} | " f"{_render_maybe_missing(_get(run, 'turns'))} | " f"{_render_maybe_missing(_get(run, 'tool_invocations'))} | " - f"{_render_token_totals(_get(run, 'input_tokens'), _get(run, 'output_tokens'), missing_text='Not available')} | " + f"{_render_maybe_missing(_get(run, 'input_tokens'), missing_text='Not available')} | " + f"{_render_maybe_missing(_get(run, 'output_tokens'), missing_text='Not available')} | " + f"{_render_maybe_missing(_observed_token_total(_get(run, 'input_tokens'), _get(run, 'output_tokens')), missing_text='Not available')} | " "" ) return f""" -{banner} {notice} -
| Status | Turns in range | Tool invocations | -Tokens | +Input tokens | +Output tokens | +Total tokens | {"".join(rows)} @@ -2128,10 +2152,9 @@ def render_models_usage_table( ``deployment``, ``requests``, ``failures``, ``p95_latency_ms``, ``input_tokens``, ``output_tokens``, and ``last_seen``. """ - banner = render_diagnostics_banner(diagnostics) if diagnostics is not None else "" if not usage: return ( - f'{banner}{_render_maybe_missing(_get(entry, 'requests'))} | " f"{_render_failure_rate(_get(entry, 'requests'), _get(entry, 'failures'))} | " f"{_render_maybe_missing(_get(entry, 'p95_latency_ms'), suffix=' ms')} | " - f"{_render_model_token_usage(entry)} | " + f"{_render_maybe_missing(_get(entry, 'input_tokens'))} | " + f"{_render_maybe_missing(_get(entry, 'output_tokens'))} | " + f"{_render_maybe_missing(_observed_token_total(_get(entry, 'input_tokens'), _get(entry, 'output_tokens')))} | " + f"{_render_maybe_missing(_get(entry, 'cache_read_tokens'))} | " + f"{_render_maybe_missing(_get(entry, 'cache_write_tokens'))} | " + f"{_render_maybe_missing(_get(entry, 'reasoning_tokens'))} | " + f"{_render_additional_token_classes(entry)} | " f"{render_last_seen(_get(entry, 'last_seen'))} | " "" ) return f""" -{banner} +
|---|
| Requests | Failure rate | p95 latency | -Tokens | +Input tokens | +Output tokens | +Total tokens | +Cache read | +Cache write | +Reasoning | +Other token classes | Last seen | @@ -2747,6 +2782,40 @@ def render_trace_detail_shell( top: 0; background: var(--observe-bg); } +.observe-sort-button { + align-items: center; + appearance: none; + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + display: inline-flex; + font: inherit; + gap: 6px; + letter-spacing: inherit; + padding: 0; + text-align: left; + text-transform: inherit; +} +.observe-sort-button::after { + color: var(--observe-muted); + content: "\2195"; + font-size: 12px; + line-height: 1; +} +th[aria-sort="ascending"] .observe-sort-button::after { + color: var(--observe-accent); + content: "\2191"; +} +th[aria-sort="descending"] .observe-sort-button::after { + color: var(--observe-accent); + content: "\2193"; +} +.observe-sort-button:focus-visible { + border-radius: 3px; + outline: 2px solid var(--observe-accent); + outline-offset: 3px; +} tbody tr:hover td { background: color-mix(in srgb, var(--observe-fg) 4%, transparent); } /* --- Badges & tones ----------------------------------------------------- */ @@ -2902,7 +2971,7 @@ def render_trace_detail_shell( // Maps each internal view identifier to the `ObserveQuery.view` wire value // 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", coverage: "coverage" }; + var VIEW_WIRE_NAMES = { overview: "overview", agents: "agents", usage: "models", tools: "tools", runs: "runs" }; // 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 @@ -3219,6 +3288,30 @@ def render_trace_detail_shell( return wrap; } + function observedTokenTotal(inputTokens, outputTokens) { + if (inputTokens === null && outputTokens === null) { + return null; + } + if (inputTokens === undefined && outputTokens === undefined) { + return null; + } + return Number(inputTokens || 0) + Number(outputTokens || 0); + } + + function renderAdditionalTokenClasses(entry) { + entry = entry || {}; + var values = Object.keys(entry.additional_token_classes || {}).map(function (name) { + return name + ": " + formatNumberJs(entry.additional_token_classes[name]); + }); + if (entry.additional_token_classes_truncated) { + values.push("Additional classes truncated"); + } + if (entry.token_classes_partial) { + values.push("Partial class coverage"); + } + return values.length ? values.join("; ") : renderMaybeMissing(null); + } + function renderModelTokenUsage(entry) { entry = entry || {}; var wrap = makeEl("span", "observe-model-token-usage"); @@ -3332,48 +3425,6 @@ def render_trace_detail_shell( return renderBadgeJs("Identity not reported", "muted", "observe-identity-badge"); } - function renderDiagnosticsBannerNode(diagnostics) { - if (!diagnostics) { - return null; - } - var banner = makeEl("div", "observe-diagnostics-banner"); - var partial = diagnostics.partial_sources || 0; - var failed = diagnostics.failed_sources || 0; - if (partial > 0 || failed > 0) { - var notice = makeEl( - "p", - "observe-partial-notice", - "Partial results: some telemetry sources did not fully respond. " + - "Data from every source that did respond is still shown below." - ); - notice.setAttribute("role", "status"); - banner.appendChild(notice); - } - var dl = makeEl("dl", "observe-diagnostics-list"); - var rows = [ - ["Sources queried", diagnostics.source_count, {}], - ["Successful", diagnostics.successful_sources, {}], - ["Partial", diagnostics.partial_sources, {}], - ["Failed", diagnostics.failed_sources, {}], - ["Query duration", diagnostics.duration_ms, { suffix: " ms" }], - ]; - rows.forEach(function (row) { - var div = document.createElement("div"); - div.appendChild(makeEl("dt", null, row[0])); - var dd = document.createElement("dd"); - dd.appendChild(renderMaybeMissing(row[1], row[2])); - div.appendChild(dd); - dl.appendChild(div); - }); - var cacheDiv = document.createElement("div"); - cacheDiv.appendChild(makeEl("dt", null, "Cache")); - cacheDiv.appendChild(makeEl("dd", null, diagnostics.cache_status || "Not reported")); - dl.appendChild(cacheDiv); - banner.appendChild(dl); - banner.appendChild(renderRefreshedAtJs(diagnostics.completed_at)); - return banner; - } - function emptyStateNode(message) { var wrap = makeEl("div", "observe-empty-state"); wrap.appendChild(makeEl("p", "observe-empty", message)); @@ -3391,6 +3442,86 @@ def render_trace_detail_shell( container.appendChild(node); } }); + enhanceSortableTables(container); + } + + function sortableCellValue(cell) { + var text = String(cell.getAttribute("data-sort-value") || cell.textContent || "").trim(); + if (!text || /^(not reported|not measured|not available)$/i.test(text)) { + return { missing: true, type: "text", value: "" }; + } + var iso = text.match(/\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?Z?/); + if (iso) { + var timestamp = Date.parse(iso[0]); + if (!Number.isNaN(timestamp)) { + return { missing: false, type: "number", value: timestamp }; + } + } + var compact = text.replace(/,/g, ""); + var numeric = compact.match(/-?\\d+(?:\\.\\d+)?/); + if (numeric && (/^-?[\\d,.]+\\s*(?:%|ms|s)?$/i.test(text) || /^(in:|last seen:)/i.test(text))) { + return { missing: false, type: "number", value: Number(numeric[0]) }; + } + return { missing: false, type: "text", value: text.toLocaleLowerCase() }; + } + + function enhanceSortableTable(table) { + if (!table || table.dataset.observeSortable === "true") { + return; + } + var body = table.tBodies && table.tBodies[0]; + var headers = table.querySelectorAll("thead th"); + if (!body || !headers.length) { + return; + } + table.dataset.observeSortable = "true"; + headers.forEach(function (header, columnIndex) { + var label = String(header.textContent || "").trim() || "Column " + (columnIndex + 1); + clearChildren(header); + header.setAttribute("aria-sort", "none"); + 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 () { + 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) { + return { row: row, index: index, value: sortableCellValue(row.cells[columnIndex] || row) }; + }); + rows.sort(function (left, right) { + if (left.value.missing !== right.value.missing) { + return left.value.missing ? 1 : -1; + } + var comparison = 0; + if (left.value.type === "number" && right.value.type === "number") { + comparison = left.value.value - right.value.value; + } else { + comparison = String(left.value.value).localeCompare(String(right.value.value)); + } + if (comparison === 0) { + comparison = left.index - right.index; + } + return direction === "ascending" ? comparison : -comparison; + }); + rows.forEach(function (entry) { + body.appendChild(entry.row); + }); + button.setAttribute( + "aria-label", + "Sort by " + label + (direction === "ascending" ? " descending" : " ascending") + ); + }); + header.appendChild(button); + }); + } + + function enhanceSortableTables(root) { + (root || document).querySelectorAll("table").forEach(enhanceSortableTable); } function buildDataTable(className, ariaLabel, columns, rows) { @@ -3424,6 +3555,7 @@ def render_trace_detail_shell( tbody.appendChild(tr); }); table.appendChild(tbody); + enhanceSortableTable(table); return table; } @@ -3436,6 +3568,21 @@ def render_trace_detail_shell( if (Array.isArray(data)) return data; if (data && Array.isArray(data.metrics)) return data.metrics; if (data && Array.isArray(data.cards)) return data.cards; + if (data && typeof data === "object" && data.invocations !== undefined) { + var invocations = Number(data.invocations || 0); + var failures = Number(data.failures || 0); + return [ + { title: "Invocations", value: invocations }, + { title: "Failures", value: failures }, + { + title: "Success rate", + value: invocations > 0 ? Math.round(((invocations - failures) / invocations) * 1000) / 10 : null, + unit: "%", + }, + { title: "Average latency", value: data.avg_latency_ms, unit: " ms" }, + { title: "p95 latency", value: data.p95_latency_ms, unit: " ms" }, + ]; + } return []; } @@ -3473,10 +3620,9 @@ def render_trace_detail_shell( } function renderOverview(data, diagnostics) { - var banner = renderDiagnosticsBannerNode(diagnostics); var metrics = overviewMetricsFrom(data); if (!metrics.length) { - setViewContent("overview", [banner, emptyStateNode("No data found for the selected filters.")]); + setViewContent("overview", [emptyStateNode("No data found for the selected filters.")]); return; } var grid = makeEl("div", "observe-overview-cards"); @@ -3498,7 +3644,7 @@ def render_trace_detail_shell( } grid.appendChild(card); }); - setViewContent("overview", [banner, grid]); + setViewContent("overview", [grid]); } function renderAgents(data, diagnostics) { @@ -3523,17 +3669,22 @@ def render_trace_detail_shell( renderMaybeMissing(agent.invocations), renderFailureRate(agent.invocations, agent.failures), renderMaybeMissing(agent.p95_latency_ms, { suffix: " ms" }), - renderTokenTotals(agent.input_tokens, agent.output_tokens), + renderMaybeMissing(agent.input_tokens), + renderMaybeMissing(agent.output_tokens), + renderMaybeMissing(observedTokenTotal(agent.input_tokens, agent.output_tokens)), buildAgentDetailButton(agent), ]; }); var table = buildDataTable( "observe-agents-table", "Agents observed in the selected range", - ["Agent", "Source", "Model", "Last seen", "Invocations", "Failure rate", "p95 latency", "Tokens", "Details"], + ["Agent", "Source", "Model", "Last seen", "Invocations", "Failure rate", "p95 latency", "Input tokens", "Output tokens", "Total tokens", "Details"], rows ); - setViewContent("agents", [table]); + setViewContent("agents", [ + makeEl("p", "observe-hint", "Token columns show observed usage, not billing data."), + table, + ]); } // --------------------------------------------------------------------- @@ -3807,10 +3958,9 @@ def render_trace_detail_shell( } function renderUsage(data, diagnostics) { - var banner = renderDiagnosticsBannerNode(diagnostics); var usage = modelsFrom(data); if (!usage.length) { - setViewContent("usage", [banner, emptyStateNode("No data found for the selected filters.")]); + setViewContent("usage", [emptyStateNode("No data found for the selected filters.")]); return; } var rows = usage.map(function (entry) { @@ -3821,28 +3971,35 @@ def render_trace_detail_shell( renderMaybeMissing(entry.requests), renderFailureRate(entry.requests, entry.failures), renderMaybeMissing(entry.p95_latency_ms, { suffix: " ms" }), - renderModelTokenUsage(entry), + renderMaybeMissing(entry.input_tokens), + renderMaybeMissing(entry.output_tokens), + renderMaybeMissing(observedTokenTotal(entry.input_tokens, entry.output_tokens)), + renderMaybeMissing(entry.cache_read_tokens), + renderMaybeMissing(entry.cache_write_tokens), + renderMaybeMissing(entry.reasoning_tokens), + renderAdditionalTokenClasses(entry), renderLastSeenJs(entry.last_seen), ]; }); var table = buildDataTable( "observe-usage-table", "Model usage observed in the selected range", - ["Model", "Deployment", "Requests", "Failure rate", "p95 latency", "Tokens", "Last seen"], + ["Model", "Deployment", "Requests", "Failure rate", "p95 latency", "Input tokens", "Output tokens", "Total tokens", "Cache read", "Cache write", "Reasoning", "Other token classes", "Last seen"], rows ); - setViewContent("usage", [banner, table]); + setViewContent("usage", [ + makeEl("p", "observe-hint", "Token columns show observed usage, not billing data."), + table, + ]); } function renderTools(data, diagnostics, bounds) { - var banner = renderDiagnosticsBannerNode(diagnostics); var tools = toolsFrom(data); var notice = boundsNoticeNode(bounds, tools.length); if (!tools.length) { setViewContent("tools", [ - banner, notice, - emptyStateNode("No tool activity was found for the selected filters. Tool attribution may not be reported; check Telemetry coverage for details."), + emptyStateNode("No tool activity was found for the selected filters. Tool attribution may not be reported for this selection."), ]); return; } @@ -3865,18 +4022,16 @@ def render_trace_detail_shell( ["Tool", "Agent", "Source", "Runtime", "Last seen", "Invocations", "Failures", "p95 latency"], rows ); - setViewContent("tools", [banner, notice, table]); + setViewContent("tools", [notice, table]); } function renderRuns(data, diagnostics, bounds) { - var banner = renderDiagnosticsBannerNode(diagnostics); var runs = runsFrom(data); var notice = boundsNoticeNode(bounds, runs.length); if (!runs.length) { setViewContent("runs", [ - banner, notice, - emptyStateNode("No runs could be correlated for the selected filters. Run correlation may not be reported; check Telemetry coverage for details."), + emptyStateNode("No runs could be correlated for the selected filters. Run correlation may not be reported for this selection."), ]); return; } @@ -3893,17 +4048,19 @@ def render_trace_detail_shell( run.status || "Not reported", renderMaybeMissing(run.turns), renderMaybeMissing(run.tool_invocations), - renderTokenTotals(run.input_tokens, run.output_tokens, "Not available"), + renderMaybeMissing(run.input_tokens, { missingText: "Not available" }), + renderMaybeMissing(run.output_tokens, { missingText: "Not available" }), + renderMaybeMissing(observedTokenTotal(run.input_tokens, run.output_tokens), { missingText: "Not available" }), ]; }); var hint = makeEl("p", "observe-hint", "Start, duration, and turns describe activity within the selected range."); var table = buildDataTable( "observe-runs-table", "Runs observed in the selected range", - ["Run key", "Correlation", "Agent", "Source", "Runtime", "Started in range", "Duration in range", "Status", "Turns in range", "Tool invocations", "Tokens"], + ["Run key", "Correlation", "Agent", "Source", "Runtime", "Started in range", "Duration in range", "Status", "Turns in range", "Tool invocations", "Input tokens", "Output tokens", "Total tokens"], rows ); - setViewContent("runs", [banner, notice, hint, table]); + setViewContent("runs", [notice, hint, table]); } function costLabel(value) { @@ -4474,10 +4631,8 @@ def render_trace_detail_shell( } function renderCost(data, diagnostics, coverage, partialFailures, bounds) { - var banner = renderDiagnosticsBannerNode(diagnostics); if (!data || typeof data !== "object") { var emptyNodes = [ - banner, emptyStateNode("No cost allocation data reported."), makeEl("p", "observe-cost-breakdown-warning", COST_BREAKDOWN_WARNING), makeEl("p", "observe-cost-disclaimer", COST_DISCLAIMER), @@ -4492,7 +4647,7 @@ def render_trace_detail_shell( return; } renderCostControlsFromData(data); - var nodes = [banner, renderCostPeriodNode(data)]; + var nodes = [renderCostPeriodNode(data)]; nodes.push(makeEl("p", "observe-cost-breakdown-warning", COST_BREAKDOWN_WARNING)); nodes.push(makeEl("h3", null, "Currency subtotals")); nodes.push(renderCostSubtotalsNode(data.currency_subtotals)); @@ -4510,36 +4665,6 @@ def render_trace_detail_shell( setViewContent("cost", nodes); } - function renderCoverage(coverage, diagnostics) { - var banner = renderDiagnosticsBannerNode(diagnostics); - coverage = Array.isArray(coverage) ? coverage : []; - if (!coverage.length) { - setViewContent("coverage", [banner, emptyStateNode("No coverage information reported.")]); - return; - } - var rows = coverage.map(function (entry) { - entry = entry || {}; - var state = entry.state || "error"; - var copy = COVERAGE_STATE_LABELS[state] || COVERAGE_STATE_LABELS.error; - var dimensionLabel = COVERAGE_DIMENSION_LABELS[entry.dimension] || (entry.dimension || "Unknown dimension"); - return [ - entry.source_id || "Not reported", - dimensionLabel, - renderBadgeJs(copy.label, copy.tone, "observe-coverage-state-" + state), - entry.reason || "Not reported", - entry.next_action || "Not reported", - renderRefreshedAtJs(entry.refreshed_at), - ]; - }); - var table = buildDataTable( - "observe-coverage-table", - "Telemetry coverage and troubleshooting detail", - ["Source", "Dimension", "State", "Reason", "Next action", "Refreshed"], - rows - ); - setViewContent("coverage", [banner, table]); - } - function renderAttributionControlsFromData(data) { var form = document.getElementById("observe-attribution-filter-form"); if (!form || !data) return; @@ -4852,8 +4977,6 @@ def render_trace_detail_shell( body.partial_failures, body.bounds ); - } else if (view === "coverage") { - renderCoverage(body.coverage, body.diagnostics); } } @@ -5174,6 +5297,7 @@ def render_trace_detail_shell( }); activateView(currentView); setupThemeToggle(); + enhanceSortableTables(document); syncUrl(); scheduleAutoRefresh(); } @@ -5227,7 +5351,7 @@ def render_observe_page( Cost and attribution are independently additive and opt-in. Their navigation, controls, and sections are absent unless explicitly enabled. - This preserves the existing six-view surface by default. + This preserves the existing operational views by default. """ effective_active_view = active_view if active_view == "cost" and not cost_enabled: @@ -5247,7 +5371,6 @@ def render_observe_page( usage_html = render_models_usage_table(usage, diagnostics=diagnostics) tools_html = render_tools_table(tools, diagnostics=diagnostics, bounds=tools_bounds) runs_html = render_runs_table(runs, diagnostics=diagnostics, bounds=runs_bounds) - coverage_html = render_coverage_view(coverage, diagnostics) cost_section = "" if cost_enabled: cost_controls = render_cost_controls( @@ -5363,11 +5486,6 @@ def render_observe_page( {attribution_section} {cost_section} -
|---|